molbuilder 1.0.0__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.
- molbuilder/__init__.py +8 -0
- molbuilder/__main__.py +6 -0
- molbuilder/atomic/__init__.py +4 -0
- molbuilder/atomic/bohr.py +235 -0
- molbuilder/atomic/quantum_atom.py +334 -0
- molbuilder/atomic/quantum_numbers.py +196 -0
- molbuilder/atomic/wavefunctions.py +297 -0
- molbuilder/bonding/__init__.py +4 -0
- molbuilder/bonding/covalent.py +442 -0
- molbuilder/bonding/lewis.py +347 -0
- molbuilder/bonding/vsepr.py +433 -0
- molbuilder/cli/__init__.py +1 -0
- molbuilder/cli/demos.py +516 -0
- molbuilder/cli/menu.py +127 -0
- molbuilder/cli/wizard.py +831 -0
- molbuilder/core/__init__.py +6 -0
- molbuilder/core/bond_data.py +170 -0
- molbuilder/core/constants.py +51 -0
- molbuilder/core/element_properties.py +183 -0
- molbuilder/core/elements.py +181 -0
- molbuilder/core/geometry.py +232 -0
- molbuilder/gui/__init__.py +2 -0
- molbuilder/gui/app.py +286 -0
- molbuilder/gui/canvas3d.py +115 -0
- molbuilder/gui/dialogs.py +117 -0
- molbuilder/gui/event_handler.py +118 -0
- molbuilder/gui/sidebar.py +105 -0
- molbuilder/gui/toolbar.py +71 -0
- molbuilder/io/__init__.py +1 -0
- molbuilder/io/json_io.py +146 -0
- molbuilder/io/mol_sdf.py +169 -0
- molbuilder/io/pdb.py +184 -0
- molbuilder/io/smiles_io.py +47 -0
- molbuilder/io/xyz.py +103 -0
- molbuilder/molecule/__init__.py +2 -0
- molbuilder/molecule/amino_acids.py +919 -0
- molbuilder/molecule/builders.py +257 -0
- molbuilder/molecule/conformations.py +70 -0
- molbuilder/molecule/functional_groups.py +484 -0
- molbuilder/molecule/graph.py +712 -0
- molbuilder/molecule/peptides.py +13 -0
- molbuilder/molecule/stereochemistry.py +6 -0
- molbuilder/process/__init__.py +3 -0
- molbuilder/process/conditions.py +260 -0
- molbuilder/process/costing.py +316 -0
- molbuilder/process/purification.py +285 -0
- molbuilder/process/reactor.py +297 -0
- molbuilder/process/safety.py +476 -0
- molbuilder/process/scale_up.py +427 -0
- molbuilder/process/solvent_systems.py +204 -0
- molbuilder/reactions/__init__.py +3 -0
- molbuilder/reactions/functional_group_detect.py +728 -0
- molbuilder/reactions/knowledge_base.py +1716 -0
- molbuilder/reactions/reaction_types.py +102 -0
- molbuilder/reactions/reagent_data.py +1248 -0
- molbuilder/reactions/retrosynthesis.py +1430 -0
- molbuilder/reactions/synthesis_route.py +377 -0
- molbuilder/reports/__init__.py +158 -0
- molbuilder/reports/cost_report.py +206 -0
- molbuilder/reports/molecule_report.py +279 -0
- molbuilder/reports/safety_report.py +296 -0
- molbuilder/reports/synthesis_report.py +283 -0
- molbuilder/reports/text_formatter.py +170 -0
- molbuilder/smiles/__init__.py +4 -0
- molbuilder/smiles/parser.py +487 -0
- molbuilder/smiles/tokenizer.py +291 -0
- molbuilder/smiles/writer.py +375 -0
- molbuilder/visualization/__init__.py +1 -0
- molbuilder/visualization/bohr_viz.py +166 -0
- molbuilder/visualization/molecule_viz.py +368 -0
- molbuilder/visualization/quantum_viz.py +434 -0
- molbuilder/visualization/theme.py +12 -0
- molbuilder-1.0.0.dist-info/METADATA +360 -0
- molbuilder-1.0.0.dist-info/RECORD +78 -0
- molbuilder-1.0.0.dist-info/WHEEL +5 -0
- molbuilder-1.0.0.dist-info/entry_points.txt +2 -0
- molbuilder-1.0.0.dist-info/licenses/LICENSE +21 -0
- molbuilder-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Cost estimation report generator.
|
|
2
|
+
|
|
3
|
+
Produces an ASCII cost breakdown report with summary, category
|
|
4
|
+
breakdown table, bar chart, percentage distribution, and notes.
|
|
5
|
+
All output is cp1252-safe.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from molbuilder.reports.text_formatter import (
|
|
13
|
+
section_header,
|
|
14
|
+
subsection_header,
|
|
15
|
+
ascii_table,
|
|
16
|
+
bullet_list,
|
|
17
|
+
key_value_block,
|
|
18
|
+
horizontal_bar,
|
|
19
|
+
format_currency,
|
|
20
|
+
format_percent,
|
|
21
|
+
word_wrap,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from molbuilder.reports import CostEstimateLike
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =====================================================================
|
|
29
|
+
# Helpers
|
|
30
|
+
# =====================================================================
|
|
31
|
+
|
|
32
|
+
_CATEGORY_LABELS: list[tuple[str, str]] = [
|
|
33
|
+
("raw_materials_usd", "Raw Materials"),
|
|
34
|
+
("labor_usd", "Labor"),
|
|
35
|
+
("equipment_usd", "Equipment"),
|
|
36
|
+
("energy_usd", "Energy"),
|
|
37
|
+
("waste_disposal_usd", "Waste Disposal"),
|
|
38
|
+
("overhead_usd", "Overhead"),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _safe_float(value, default: float = 0.0) -> float:
|
|
43
|
+
"""Coerce *value* to float, returning *default* on failure."""
|
|
44
|
+
if value is None:
|
|
45
|
+
return default
|
|
46
|
+
try:
|
|
47
|
+
return float(value)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
return default
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# =====================================================================
|
|
53
|
+
# Public API
|
|
54
|
+
# =====================================================================
|
|
55
|
+
|
|
56
|
+
def generate_cost_report(estimate: CostEstimateLike) -> str:
|
|
57
|
+
"""Generate an ASCII cost breakdown report.
|
|
58
|
+
|
|
59
|
+
Uses duck typing -- *estimate* should expose:
|
|
60
|
+
|
|
61
|
+
* ``.total_usd`` -- float, total estimated cost
|
|
62
|
+
* ``.per_kg_usd`` -- float, cost per kilogram of product
|
|
63
|
+
* ``.scale_kg`` -- float, production scale in kg
|
|
64
|
+
* ``.breakdown`` -- object with attributes:
|
|
65
|
+
- ``.raw_materials_usd``
|
|
66
|
+
- ``.labor_usd``
|
|
67
|
+
- ``.equipment_usd``
|
|
68
|
+
- ``.energy_usd``
|
|
69
|
+
- ``.waste_disposal_usd``
|
|
70
|
+
- ``.overhead_usd``
|
|
71
|
+
* ``.notes`` -- list[str], assumptions and caveats
|
|
72
|
+
"""
|
|
73
|
+
if not hasattr(estimate, 'total_usd'):
|
|
74
|
+
raise TypeError(
|
|
75
|
+
f"estimate must have a 'total_usd' attribute, "
|
|
76
|
+
f"got {type(estimate).__name__}"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
lines: list[str] = []
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# 1. Header
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
lines.append(section_header("Cost Estimation Report"))
|
|
85
|
+
lines.append("")
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
# 2. Summary
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
lines.append(subsection_header("Summary"))
|
|
91
|
+
|
|
92
|
+
total = _safe_float(getattr(estimate, "total_usd", None))
|
|
93
|
+
per_kg = _safe_float(getattr(estimate, "per_kg_usd", None))
|
|
94
|
+
scale = _safe_float(getattr(estimate, "scale_kg", None))
|
|
95
|
+
|
|
96
|
+
summary_pairs = [
|
|
97
|
+
("Production Scale", f"{scale:.2f} kg"),
|
|
98
|
+
("Total Cost", format_currency(total)),
|
|
99
|
+
("Cost per kg", format_currency(per_kg)),
|
|
100
|
+
]
|
|
101
|
+
lines.append(key_value_block(summary_pairs))
|
|
102
|
+
lines.append("")
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# 3. Cost Breakdown -- table and bar chart
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
lines.append(subsection_header("Cost Breakdown"))
|
|
108
|
+
|
|
109
|
+
breakdown = getattr(estimate, "breakdown", None)
|
|
110
|
+
categories: list[tuple[str, float]] = []
|
|
111
|
+
for attr, label in _CATEGORY_LABELS:
|
|
112
|
+
val = _safe_float(getattr(breakdown, attr, None) if breakdown else None)
|
|
113
|
+
categories.append((label, val))
|
|
114
|
+
|
|
115
|
+
# Table
|
|
116
|
+
tbl_headers = ["Category", "Amount (USD)", "% of Total"]
|
|
117
|
+
tbl_rows: list[list[str]] = []
|
|
118
|
+
for label, amount in categories:
|
|
119
|
+
pct = (amount / total * 100.0) if total > 0 else 0.0
|
|
120
|
+
tbl_rows.append([
|
|
121
|
+
label,
|
|
122
|
+
format_currency(amount),
|
|
123
|
+
format_percent(pct),
|
|
124
|
+
])
|
|
125
|
+
# Total row
|
|
126
|
+
tbl_rows.append([
|
|
127
|
+
"TOTAL",
|
|
128
|
+
format_currency(total),
|
|
129
|
+
format_percent(100.0) if total > 0 else format_percent(0.0),
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
lines.append(ascii_table(
|
|
133
|
+
tbl_headers, tbl_rows,
|
|
134
|
+
alignments=["l", "r", "r"],
|
|
135
|
+
min_widths=[16, 14, 10],
|
|
136
|
+
))
|
|
137
|
+
lines.append("")
|
|
138
|
+
|
|
139
|
+
# Bar chart
|
|
140
|
+
lines.append(subsection_header("Cost Distribution (Bar Chart)"))
|
|
141
|
+
max_amount = max((amt for _, amt in categories), default=0.0)
|
|
142
|
+
max_label_len = max((len(label) for label, _ in categories), default=0)
|
|
143
|
+
|
|
144
|
+
for label, amount in categories:
|
|
145
|
+
bar = horizontal_bar(amount, max_amount, width=35, char="#")
|
|
146
|
+
amount_str = format_currency(amount)
|
|
147
|
+
lines.append(
|
|
148
|
+
f" {label:<{max_label_len}} |{bar}| {amount_str}"
|
|
149
|
+
)
|
|
150
|
+
lines.append("")
|
|
151
|
+
|
|
152
|
+
# ------------------------------------------------------------------
|
|
153
|
+
# 4. Percentage Distribution
|
|
154
|
+
# ------------------------------------------------------------------
|
|
155
|
+
lines.append(subsection_header("Percentage Distribution"))
|
|
156
|
+
|
|
157
|
+
pct_headers = ["Category", "Percentage", "Visual"]
|
|
158
|
+
pct_rows: list[list[str]] = []
|
|
159
|
+
for label, amount in categories:
|
|
160
|
+
pct = (amount / total * 100.0) if total > 0 else 0.0
|
|
161
|
+
bar = horizontal_bar(pct, 100.0, width=20, char="=")
|
|
162
|
+
pct_rows.append([label, format_percent(pct), bar])
|
|
163
|
+
|
|
164
|
+
lines.append(ascii_table(
|
|
165
|
+
pct_headers, pct_rows,
|
|
166
|
+
alignments=["l", "r", "l"],
|
|
167
|
+
min_widths=[16, 10, 20],
|
|
168
|
+
))
|
|
169
|
+
lines.append("")
|
|
170
|
+
|
|
171
|
+
# ------------------------------------------------------------------
|
|
172
|
+
# 5. Notes and Assumptions
|
|
173
|
+
# ------------------------------------------------------------------
|
|
174
|
+
lines.append(subsection_header("Notes and Assumptions"))
|
|
175
|
+
|
|
176
|
+
notes = getattr(estimate, "notes", None)
|
|
177
|
+
if notes and hasattr(notes, "__iter__") and not isinstance(notes, str):
|
|
178
|
+
note_list = [str(n) for n in notes if n]
|
|
179
|
+
elif notes:
|
|
180
|
+
note_list = [str(notes)]
|
|
181
|
+
else:
|
|
182
|
+
note_list = []
|
|
183
|
+
|
|
184
|
+
if note_list:
|
|
185
|
+
lines.append(bullet_list(note_list))
|
|
186
|
+
else:
|
|
187
|
+
lines.append(" No additional notes.")
|
|
188
|
+
lines.append("")
|
|
189
|
+
|
|
190
|
+
# Standard disclaimer
|
|
191
|
+
lines.append(subsection_header("Disclaimer"))
|
|
192
|
+
disclaimer = (
|
|
193
|
+
"This cost estimate is for planning purposes only. Actual costs "
|
|
194
|
+
"may vary based on supplier pricing, scale-up effects, local "
|
|
195
|
+
"labor rates, and regulatory requirements. All figures are "
|
|
196
|
+
"approximate and should be validated before procurement."
|
|
197
|
+
)
|
|
198
|
+
lines.append(word_wrap(disclaimer, width=66, indent=2))
|
|
199
|
+
lines.append("")
|
|
200
|
+
|
|
201
|
+
# Footer
|
|
202
|
+
lines.append("=" * 70)
|
|
203
|
+
lines.append(" End of Cost Estimation Report")
|
|
204
|
+
lines.append("=" * 70)
|
|
205
|
+
|
|
206
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Molecule report generator.
|
|
2
|
+
|
|
3
|
+
Produces a comprehensive ASCII report about a molecule's composition,
|
|
4
|
+
bonding, functional groups, and connectivity. All output is cp1252-safe.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import Counter
|
|
10
|
+
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from molbuilder.reports.text_formatter import (
|
|
14
|
+
section_header,
|
|
15
|
+
subsection_header,
|
|
16
|
+
ascii_table,
|
|
17
|
+
bullet_list,
|
|
18
|
+
key_value_block,
|
|
19
|
+
format_percent,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from molbuilder.reports import MoleculeLike
|
|
24
|
+
|
|
25
|
+
# Attempt to import functional-group detection. If the reactions module
|
|
26
|
+
# is unavailable the report simply omits that section.
|
|
27
|
+
try:
|
|
28
|
+
from molbuilder.reactions.functional_group_detect import detect_functional_groups as _detect_fg
|
|
29
|
+
_HAS_FG_DETECT = True
|
|
30
|
+
except Exception:
|
|
31
|
+
_HAS_FG_DETECT = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Standard atomic weights for the most common organic elements (g/mol).
|
|
35
|
+
_ATOMIC_WEIGHTS: dict[str, float] = {
|
|
36
|
+
"H": 1.008,
|
|
37
|
+
"He": 4.003,
|
|
38
|
+
"Li": 6.941,
|
|
39
|
+
"Be": 9.012,
|
|
40
|
+
"B": 10.811,
|
|
41
|
+
"C": 12.011,
|
|
42
|
+
"N": 14.007,
|
|
43
|
+
"O": 15.999,
|
|
44
|
+
"F": 18.998,
|
|
45
|
+
"Ne": 20.180,
|
|
46
|
+
"Na": 22.990,
|
|
47
|
+
"Mg": 24.305,
|
|
48
|
+
"Al": 26.982,
|
|
49
|
+
"Si": 28.086,
|
|
50
|
+
"P": 30.974,
|
|
51
|
+
"S": 32.065,
|
|
52
|
+
"Cl": 35.453,
|
|
53
|
+
"Ar": 39.948,
|
|
54
|
+
"K": 39.098,
|
|
55
|
+
"Ca": 40.078,
|
|
56
|
+
"Ti": 47.867,
|
|
57
|
+
"Cr": 51.996,
|
|
58
|
+
"Mn": 54.938,
|
|
59
|
+
"Fe": 55.845,
|
|
60
|
+
"Co": 58.933,
|
|
61
|
+
"Ni": 58.693,
|
|
62
|
+
"Cu": 63.546,
|
|
63
|
+
"Zn": 65.380,
|
|
64
|
+
"Br": 79.904,
|
|
65
|
+
"I": 126.904,
|
|
66
|
+
"Pd": 106.42,
|
|
67
|
+
"Sn": 118.71,
|
|
68
|
+
"Pt": 195.08,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _hill_formula(counts: dict[str, int]) -> str:
|
|
73
|
+
"""Return molecular formula in Hill system order.
|
|
74
|
+
|
|
75
|
+
Hill system: C first, then H, then everything else alphabetically.
|
|
76
|
+
If no carbon, all elements alphabetically.
|
|
77
|
+
"""
|
|
78
|
+
parts: list[str] = []
|
|
79
|
+
remaining = dict(counts)
|
|
80
|
+
|
|
81
|
+
if "C" in remaining:
|
|
82
|
+
n = remaining.pop("C")
|
|
83
|
+
parts.append("C" if n == 1 else f"C{n}")
|
|
84
|
+
if "H" in remaining:
|
|
85
|
+
n = remaining.pop("H")
|
|
86
|
+
parts.append("H" if n == 1 else f"H{n}")
|
|
87
|
+
|
|
88
|
+
for elem in sorted(remaining.keys()):
|
|
89
|
+
n = remaining[elem]
|
|
90
|
+
parts.append(elem if n == 1 else f"{elem}{n}")
|
|
91
|
+
|
|
92
|
+
return "".join(parts)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _molecular_weight(counts: dict[str, int]) -> float:
|
|
96
|
+
"""Compute molecular weight from element counts."""
|
|
97
|
+
total = 0.0
|
|
98
|
+
for elem, n in counts.items():
|
|
99
|
+
total += _ATOMIC_WEIGHTS.get(elem, 0.0) * n
|
|
100
|
+
return total
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bond_order_label(order) -> str:
|
|
104
|
+
"""Human-readable bond order label."""
|
|
105
|
+
try:
|
|
106
|
+
order_val = float(order)
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
return "unknown"
|
|
109
|
+
if order_val == 1.0:
|
|
110
|
+
return "single"
|
|
111
|
+
if order_val == 1.5:
|
|
112
|
+
return "aromatic"
|
|
113
|
+
if order_val == 2.0:
|
|
114
|
+
return "double"
|
|
115
|
+
if order_val == 3.0:
|
|
116
|
+
return "triple"
|
|
117
|
+
return f"order {order_val}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# =====================================================================
|
|
121
|
+
# Public API
|
|
122
|
+
# =====================================================================
|
|
123
|
+
|
|
124
|
+
def generate_molecule_report(mol: MoleculeLike) -> str:
|
|
125
|
+
"""Generate a comprehensive ASCII report about a molecule.
|
|
126
|
+
|
|
127
|
+
Uses duck typing -- *mol* should expose:
|
|
128
|
+
|
|
129
|
+
* ``.name`` -- molecule name (str)
|
|
130
|
+
* ``.atoms`` -- list of atom objects with ``.symbol``
|
|
131
|
+
* ``.bonds`` -- list of bond objects with ``.atom_i``, ``.atom_j``, ``.order``
|
|
132
|
+
* ``.neighbors(idx)`` -- list of bonded-atom indices
|
|
133
|
+
* ``.get_bond(i, j)`` -- Bond object or None
|
|
134
|
+
|
|
135
|
+
Returns a single multi-line string.
|
|
136
|
+
"""
|
|
137
|
+
if not hasattr(mol, 'atoms'):
|
|
138
|
+
raise TypeError(
|
|
139
|
+
f"mol must have an 'atoms' attribute, "
|
|
140
|
+
f"got {type(mol).__name__}"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# 1. Header
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
mol_name = getattr(mol, "name", "Unknown") or "Unknown"
|
|
149
|
+
lines.append(section_header(f"Molecule Report: {mol_name}"))
|
|
150
|
+
lines.append("")
|
|
151
|
+
|
|
152
|
+
atoms = getattr(mol, "atoms", [])
|
|
153
|
+
bonds = getattr(mol, "bonds", [])
|
|
154
|
+
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
# 2. Basic Properties
|
|
157
|
+
# ------------------------------------------------------------------
|
|
158
|
+
elem_counts: Counter[str] = Counter()
|
|
159
|
+
for atom in atoms:
|
|
160
|
+
elem_counts[atom.symbol] += 1
|
|
161
|
+
|
|
162
|
+
formula = _hill_formula(dict(elem_counts))
|
|
163
|
+
mw = _molecular_weight(dict(elem_counts))
|
|
164
|
+
|
|
165
|
+
lines.append(subsection_header("Basic Properties"))
|
|
166
|
+
props = [
|
|
167
|
+
("Molecular Formula", formula),
|
|
168
|
+
("Molecular Weight", f"{mw:.3f} g/mol"),
|
|
169
|
+
("Total Atoms", str(len(atoms))),
|
|
170
|
+
("Total Bonds", str(len(bonds))),
|
|
171
|
+
]
|
|
172
|
+
lines.append(key_value_block(props))
|
|
173
|
+
lines.append("")
|
|
174
|
+
|
|
175
|
+
# ------------------------------------------------------------------
|
|
176
|
+
# 3. Atom Composition
|
|
177
|
+
# ------------------------------------------------------------------
|
|
178
|
+
lines.append(subsection_header("Atom Composition"))
|
|
179
|
+
comp_headers = ["Element", "Count", "Mass (g/mol)", "Mass %"]
|
|
180
|
+
comp_rows: list[list[str]] = []
|
|
181
|
+
for elem in sorted(elem_counts.keys()):
|
|
182
|
+
count = elem_counts[elem]
|
|
183
|
+
mass = _ATOMIC_WEIGHTS.get(elem, 0.0) * count
|
|
184
|
+
pct = (mass / mw * 100.0) if mw > 0 else 0.0
|
|
185
|
+
comp_rows.append([
|
|
186
|
+
elem,
|
|
187
|
+
str(count),
|
|
188
|
+
f"{mass:.3f}",
|
|
189
|
+
format_percent(pct),
|
|
190
|
+
])
|
|
191
|
+
lines.append(ascii_table(
|
|
192
|
+
comp_headers, comp_rows,
|
|
193
|
+
alignments=["l", "r", "r", "r"],
|
|
194
|
+
min_widths=[8, 6, 12, 8],
|
|
195
|
+
))
|
|
196
|
+
lines.append("")
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------
|
|
199
|
+
# 4. Bond Summary
|
|
200
|
+
# ------------------------------------------------------------------
|
|
201
|
+
lines.append(subsection_header("Bond Summary"))
|
|
202
|
+
order_counts: Counter[str] = Counter()
|
|
203
|
+
for bond in bonds:
|
|
204
|
+
label = _bond_order_label(bond.order)
|
|
205
|
+
order_counts[label] += 1
|
|
206
|
+
|
|
207
|
+
bond_headers = ["Bond Type", "Count"]
|
|
208
|
+
bond_rows = [[btype, str(cnt)]
|
|
209
|
+
for btype, cnt in sorted(order_counts.items())]
|
|
210
|
+
lines.append(ascii_table(
|
|
211
|
+
bond_headers, bond_rows,
|
|
212
|
+
alignments=["l", "r"],
|
|
213
|
+
min_widths=[12, 6],
|
|
214
|
+
))
|
|
215
|
+
lines.append("")
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# 5. Functional Groups
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
if _HAS_FG_DETECT:
|
|
221
|
+
lines.append(subsection_header("Functional Groups"))
|
|
222
|
+
try:
|
|
223
|
+
groups = _detect_fg(mol)
|
|
224
|
+
if groups:
|
|
225
|
+
fg_names = [g.name for g in groups]
|
|
226
|
+
fg_counts: Counter[str] = Counter(fg_names)
|
|
227
|
+
fg_items = [
|
|
228
|
+
f"{name} (x{cnt})" if cnt > 1 else name
|
|
229
|
+
for name, cnt in sorted(fg_counts.items())
|
|
230
|
+
]
|
|
231
|
+
lines.append(bullet_list(fg_items))
|
|
232
|
+
else:
|
|
233
|
+
lines.append(" No common functional groups detected.")
|
|
234
|
+
except Exception:
|
|
235
|
+
lines.append(" Functional group detection unavailable.")
|
|
236
|
+
lines.append("")
|
|
237
|
+
|
|
238
|
+
# ------------------------------------------------------------------
|
|
239
|
+
# 6. Connectivity
|
|
240
|
+
# ------------------------------------------------------------------
|
|
241
|
+
lines.append(subsection_header("Connectivity"))
|
|
242
|
+
|
|
243
|
+
# Degree distribution
|
|
244
|
+
degree_counts: Counter[int] = Counter()
|
|
245
|
+
for idx in range(len(atoms)):
|
|
246
|
+
try:
|
|
247
|
+
nbrs = mol.neighbors(idx)
|
|
248
|
+
degree_counts[len(nbrs)] += 1
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
|
|
252
|
+
if degree_counts:
|
|
253
|
+
deg_headers = ["Degree", "Atom Count"]
|
|
254
|
+
deg_rows = [[str(d), str(c)]
|
|
255
|
+
for d, c in sorted(degree_counts.items())]
|
|
256
|
+
lines.append(ascii_table(
|
|
257
|
+
deg_headers, deg_rows,
|
|
258
|
+
alignments=["r", "r"],
|
|
259
|
+
min_widths=[8, 12],
|
|
260
|
+
))
|
|
261
|
+
lines.append("")
|
|
262
|
+
|
|
263
|
+
# Ring detection heuristic (Euler formula: rings = bonds - atoms + 1
|
|
264
|
+
# for connected graph). This gives the cyclomatic number.
|
|
265
|
+
n_atoms = len(atoms)
|
|
266
|
+
n_bonds = len(bonds)
|
|
267
|
+
ring_count = n_bonds - n_atoms + 1
|
|
268
|
+
if ring_count > 0:
|
|
269
|
+
lines.append(f" Ring structures detected (cyclomatic number: {ring_count})")
|
|
270
|
+
else:
|
|
271
|
+
lines.append(" No ring structures detected (acyclic molecule)")
|
|
272
|
+
lines.append("")
|
|
273
|
+
|
|
274
|
+
# Footer
|
|
275
|
+
lines.append("=" * 70)
|
|
276
|
+
lines.append(" End of Molecule Report")
|
|
277
|
+
lines.append("=" * 70)
|
|
278
|
+
|
|
279
|
+
return "\n".join(lines)
|