paths.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. # -*- coding: utf-8 -*-
  2. r"""观澜 v2 路径中心 —— 跨平台部署的唯一路径真源 (2026-09-11 用户令)。
  3. ## 约定 (硬约束)
  4. 1. **代码与配置里只写相对路径**, 相对 **安装根 (ROOT)** —— 不写机器相关绝对路径
  5. (`/Users/…`、`/Volumes/…`、`C:\…`、`D:\…`、`F:\…`)。
  6. 2. 运行时**由本模块解析成绝对路径**, 解析基准是 ROOT —— **不是 cwd**。
  7. cwd 相对是隐形坑: 从别处调用同一个脚本 (`python <绝对路径>/scripts/x.py`) 或换工作目录,
  8. 路径就会指到别处; 本仓 2026-09-11 实测有 20+ 处 `Path('outputs/rudong/…')` 属此类, 已全部归到本模块。
  9. 3. 需要写进产物/清单/页面的路径字符串, 用 **POSIX 相对形式** (`outputs/rudong/…`, 见 `rel()`),
  10. Windows 与 Linux 通用; 不要用 `os.sep` 拼库存字符串 (那只适合"给人看"的显示, 见 `disp()`)。
  11. 4. ROOT 的确定: 环境变量 `WINDSCADA_ROOT` (冻结打包时由启动器设) → 否则按本文件位置回溯
  12. (`src/paths.py` 的上一级) —— 因此**整个安装目录可以整个拷到别的电脑/别的盘**, 不需要改任何路径。
  13. ## 跨平台要点
  14. - 分隔符: 一律 `pathlib`, 需要字符串时 `.as_posix()`;
  15. - 大小写: Linux 区分大小写 —— 目录名只用本模块常量, 不靠大小写变体;
  16. - 解释器: venv 按平台探测 (`.venv/Scripts/python.exe` / `.venv/bin/python`, 见 `venv_python()`);
  17. - 中文目录名 (如 `data/raw/如东`、`故障报警`): UTF-8 存写, 两侧平台均可; 页面显示走 `disp()`。
  18. """
  19. from __future__ import annotations
  20. try:
  21. from ._root import install_root as _install_root
  22. except ImportError: # 直接当脚本跑(python <本文件>)时没有包上下文
  23. from _root import install_root as _install_root
  24. import os
  25. import pathlib
  26. import sys
  27. # ---- 安装根: env → 本文件位置回溯 (src/paths.py → <安装目录>) ----------------------------
  28. ROOT = pathlib.Path(os.environ.get('WINDSCADA_ROOT') or _install_root(__file__))
  29. # ---- 输入侧 (离线数据): data/raw, 下一级目录 = 场站名 (见 docs/数据目录结构与落位约定) ----
  30. RAW_ROOT = pathlib.Path(os.environ.get('WINDSCADA_RUDONG_SRC') or (ROOT / 'data' / 'raw'))
  31. # ---- 非场站维度的固定位置 (全部为 ROOT 相对) -------------------------------------------
  32. CONFIGS = ROOT / 'configs'
  33. FARMS = CONFIGS / 'farms'
  34. REFERENCE = ROOT / 'reference'
  35. RELEASE = ROOT / 'release'
  36. PORTAL = RELEASE / 'portal.html'
  37. VIEWER = RELEASE / 'viewer'
  38. RESOURCES = ROOT / 'resources'
  39. SIM_DIR = RESOURCES / 'oem_envision_sc1_rudong2014'
  40. DOCS = ROOT / 'docs'
  41. LOGS = ROOT / 'logs'
  42. RUN = ROOT / 'run'
  43. SCRIPTS = ROOT / 'scripts'
  44. SRC = ROOT / 'src'
  45. WHEELS = ROOT / 'wheels'
  46. DATA = ROOT / 'data'
  47. SERVE_JSON = CONFIGS / 'serve.json'
  48. MODELS_JSON = CONFIGS / 'models.json'
  49. # ---- 配置目录约定与唯一取用口 (2026-09-17 用户令 2: 统一配置目录及配置文件) ----------------
  50. # 为什么要有这一节: 原来各模块自己拼 `ROOT / 'configs' / 'xxx.yaml'` (实测 9 个模块各拼各的),
  51. # 于是"配置放哪"随时间漂移: 顶层散着 serve.json/models.json/portal_pages.yaml, 还混进过一个
  52. # `serve.json.bak-bomfix`; `scripts/audit_chinese_terms.py` 甚至要**试三个位置**才找得到 terms 库。
  53. # 现在: 目录分工写死在下面, 代码**只从 `config()` / `config_dir()` 取路径**, 不许再手拼字符串;
  54. # `scripts/config_audit.py` 会按这套约定查实物与代码(见 docs §11)。
  55. #
  56. # configs/<域>/<名字>.<yaml|json|csv> 域 = canonical | contracts | farms | terms | <新增>
  57. # configs/serve.json 运行期单件配置 (端口/路径真源)
  58. # configs/models.json 运行期单件配置 (本机模型档)
  59. # configs/portal_pages.yaml 运行期单件配置 (门户页面归口登记表, 见 §10)
  60. TOP_LEVEL_CONFIGS = ('serve.json', 'models.json', 'portal_pages.yaml')
  61. CONFIG_DOMAINS = ('canonical', 'contracts', 'farms', 'terms')
  62. CONFIG_EXTS = ('.yaml', '.yml', '.json', '.csv')
  63. def config(*parts: str, must_exist: bool = False) -> pathlib.Path:
  64. """配置文件的唯一取用口: `P.config('terms', 'display_map.yaml')` / `P.config('serve.json')`。
  65. 只做路径解析, 不读文件 (读法由调用方决定: yaml/json/csv 各不相同);
  66. `must_exist=True` 时不存在就抛 FileNotFoundError —— 配置缺失应该在启动时报出来, 别静默用默认值。
  67. """
  68. p = CONFIGS.joinpath(*parts)
  69. if must_exist and not p.is_file():
  70. raise FileNotFoundError(f'缺配置文件 {p} (约定见 src/paths.py 配置一节 / docs §11)')
  71. return p
  72. def config_dir(*parts: str) -> pathlib.Path:
  73. """配置目录 (域) 的取用口: `P.config_dir('farms')`。"""
  74. return CONFIGS.joinpath(*parts)
  75. def farm_config(name: str | None = None) -> pathlib.Path | None:
  76. """场定义配置文件 —— **格式统一为 YAML**, 兼容历史 `.json` (有就优先用)。
  77. 2026-09-17 实测的坑: `configs/farms/` 下有 8 个场定义是 `.yaml`, 而 `available()` 只认 `*.json`
  78. ⇒ 这些场**根本列不出来**(等于配置写了没人看见); 目录里那个模板还叫 `_模板.json.example`,
  79. 与实物格式相反。现在两边都认, 且约定"新的场定义写 yaml"。
  80. """
  81. f = farm(name)
  82. for ext in ('.yaml', '.yml', '.json'):
  83. p = FARMS / f'{f}{ext}'
  84. if p.is_file():
  85. return p
  86. return None
  87. def farm(name: str | None = None) -> str:
  88. """当前场名: 显式 → env WINDSCADA_FARM → 'rudong'。
  89. 这里不 import src.windscada.config 以免循环 (config 反过来要用本模块)。
  90. 需要 set_current() 那种运行期切换时, 调用方把场名显式传进来即可。"""
  91. return name or os.environ.get('WINDSCADA_FARM') or 'rudong'
  92. # ---- 产物侧: outputs/<场名>/… (页面取数的仓) -------------------------------------------
  93. def out_root(name: str | None = None) -> pathlib.Path:
  94. return ROOT / 'outputs' / farm(name)
  95. def store(name: str | None = None) -> pathlib.Path:
  96. """L0 标准仓 (parquet) —— 页面主取数处。"""
  97. return out_root(name) / 'windscada'
  98. def ont(name: str | None = None) -> pathlib.Path:
  99. """本体对象库目录 (objects.json / 检索索引 / turbine_params)。"""
  100. return out_root(name) / 'ontology'
  101. def objects_json(name: str | None = None) -> pathlib.Path:
  102. return ont(name) / 'objects.json'
  103. def cms(name: str | None = None) -> pathlib.Path:
  104. """CMS 振动诊断产物目录。"""
  105. return out_root(name) / 'windcms'
  106. def m5(name: str | None = None) -> pathlib.Path:
  107. """振动线 handoff / TCM 兼容件目录。"""
  108. return out_root(name) / 'm5_cms_tcm'
  109. def tcm_replay(name: str | None = None) -> pathlib.Path:
  110. return out_root(name) / 'tcm_compatible_replay'
  111. def sop(name: str | None = None) -> pathlib.Path:
  112. """SOP 中间件与评审落盘目录。"""
  113. return out_root(name) / 'sop'
  114. def guanlan(name: str | None = None) -> pathlib.Path:
  115. """事实契约与对外派生 (可上云面孔)。"""
  116. return out_root(name) / 'guanlan'
  117. def pitch(name: str | None = None) -> pathlib.Path:
  118. return out_root(name) / 'pitch'
  119. def paradigm(name: str | None = None) -> pathlib.Path:
  120. """范式实验件 (E3/E5/E8 底稿) —— 事实契约的输入之一 (2026-09-16 补: 原先直接用
  121. `ROOT/'outputs'/'rudong'/'paradigm_r1'` 拼, 既写死场名又绕过了本模块)。"""
  122. return out_root(name) / 'paradigm_r1'
  123. def report_dir(name: str | None = None) -> pathlib.Path:
  124. """报告交付件目录 (`交接_振动→状态评估报告_*.md` / `现场单_*.md`) —— 由振动线出件,
  125. 并被 `src/windcms/config.py` 的 knowledge_docs 引用 (2026-09-16 补: 该目录在 v0.2.0 里
  126. 没有明确归属, 一直以 `out_root()/'report'` 的裸拼形式出现)。"""
  127. return out_root(name) / 'report'
  128. def cloud(name: str | None = None) -> pathlib.Path:
  129. """可上云面孔 (脱敏后的契约/派生件/页面) —— `scripts/guanlan_cloud_*.py` 的落点。"""
  130. return guanlan(name) / 'cloud'
  131. def contract(name: str | None = None) -> pathlib.Path:
  132. """场契约 (机型判据参数), 属 reference 侧, 不在 outputs。"""
  133. return REFERENCE / farm(name) / 'windscada_contract.yaml'
  134. def station_dir(name: str | None = None) -> pathlib.Path:
  135. """本场原始件目录 data/raw/<场站名称> —— 具体由场配置扫描结果决定,
  136. 这里只给"约定位置"兜底 (场配置里 windscada.config.farm()['raw_station_dir'] 才是权威)。"""
  137. return RAW_ROOT / farm(name)
  138. # ---- 解释器与字符串形式 ----------------------------------------------------------------
  139. def venv_python() -> pathlib.Path | None:
  140. """本安装目录下的 venv 解释器 (跨平台); 不存在返回 None。"""
  141. for rel in (('Scripts', 'python.exe'), ('bin', 'python'), ('bin', 'python3')):
  142. p = ROOT / '.venv' / pathlib.Path(*rel)
  143. if p.exists():
  144. return p
  145. return None
  146. def resolve(p) -> pathlib.Path:
  147. """把"可能是相对路径"的值解析成绝对路径: 相对基准是 ROOT (**不是 cwd**)。"""
  148. q = pathlib.Path(p)
  149. return q if q.is_absolute() else (ROOT / q)
  150. def rel(p) -> str:
  151. """给人/给清单的**相对**路径字符串 (POSIX 形式); 不在 ROOT 内则给绝对 POSIX。"""
  152. q = pathlib.Path(p)
  153. try:
  154. return q.resolve().relative_to(ROOT.resolve()).as_posix()
  155. except ValueError:
  156. return q.as_posix()
  157. def disp(p) -> str:
  158. """给人看的显示路径: 安装目录内的写成 `<安装目录>/…`, 分隔符随本机; 其余原样绝对路径。
  159. (只用于显示 —— 不要把它写进产物或清单。)"""
  160. q = pathlib.Path(p)
  161. try:
  162. return str(pathlib.Path('<安装目录>') / q.resolve().relative_to(ROOT.resolve()))
  163. except ValueError:
  164. return str(q)
  165. # 显示用分隔符: 只服务于给人看的文本 (页面「位置」列)。写进产物/清单的路径请用 rel() 的 POSIX 形式。
  166. SEP = os.sep
  167. def disp_dir(p) -> str:
  168. """目录的**显示形**: 末尾带本机分隔符 (页面「位置」列用).
  169. 路径本身一律走 `rel()` 的 POSIX 形式; 这里的 os.sep 只服务于"给人看"。"""
  170. return disp(p) + os.sep
  171. def python_exe() -> str:
  172. """跑子进程/脚本用的解释器: venv → 否则当前解释器。"""
  173. v = venv_python()
  174. return str(v) if v else sys.executable
  175. def platform_tag() -> str:
  176. """平台标识 (探测脚本/日志用): windows / linux / darwin。"""
  177. return {'nt': 'windows', 'posix': 'linux'}.get(os.name, os.name) if sys.platform != 'darwin' else 'darwin'
  178. if __name__ == '__main__': # 自检: python -m src.paths 或 python src/paths.py
  179. print(f'ROOT : {ROOT} (存在: {ROOT.is_dir()})')
  180. print(f'平台 : {platform_tag()} cwd: {pathlib.Path.cwd()}')
  181. print(f'原始件根 : {rel(RAW_ROOT)} (存在: {RAW_ROOT.is_dir()})')
  182. print(f'venv 解释器: {venv_python() or "(无, 用 " + sys.executable + ")"}')
  183. for label, p in (('store', store()), ('ontology', ont()), ('windcms', cms()),
  184. ('m5', m5()), ('sop', sop()), ('guanlan', guanlan()),
  185. ('release', RELEASE), ('sim_dir', SIM_DIR)):
  186. print(f' {label:9s} {rel(p):42s} 存在={p.exists()}')