4 Commits
Author SHA1 Message Date
jiang 2581631b51 feat(simulation): 支持冲洗阀门状态与设置值
Generic Container CI/CD / test-build-publish (push) Successful in 2m39s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m40s
2026-08-17 18:28:57 +08:00
jiang c250e97b87 ci(backend): block releases missing frontend API contract 2026-08-11 11:11:39 +08:00
jiang 69a7d53aff ci: replace webhook deployment with v2 workflow
Generic Container CI/CD / test-build-publish (push) Successful in 2m32s
Server CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m32s
2026-08-11 10:17:01 +08:00
jiang e4975b7be3 fix(container): exclude runtime configuration from image 2026-08-11 10:10:21 +08:00
10 changed files with 423 additions and 290 deletions
+5 -2
View File
@@ -11,9 +11,12 @@ dist/
package/
temp/
data/
# db_inp/
db_inp/
inp/
# .env
.env
.env.*
logs/
coverage/
*.pyc
*.dump
app/algorithms/health/model/my_survival_forest_model_quxi.joblib
+16 -252
View File
@@ -1,263 +1,27 @@
name: Server CI/CD
name: Server CI/CD v2
on:
push:
tags:
- "v*"
- "latest"
workflow_dispatch: {}
jobs:
docker-image:
runs-on: ubuntu-22.04
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: https://gitea.waternetwork.cn/actions/checkout@v4
build-test-publish-and-deploy:
uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
with:
fetch-depth: 1
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
RAW_REF_NAME: ${{ github.ref_name }}
run: |
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "${RAW_REGISTRY_HOST}" ]; then
echo "Missing required repository variable: REGISTRY_HOST"
exit 1
fi
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
if [ -z "${REGISTRY_HOST}" ]; then
echo "Repository variable REGISTRY_HOST resolves to an empty host"
exit 1
fi
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_OWNER="${REPOSITORY_PATH%%/*}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "${IMAGE_OWNER}/tjwater-backend" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
IMAGE_TAG="${RAW_REF_NAME}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
env:
image_name: gitea.waternetwork.cn/orgtjwater/tjwater-backend
dockerfile: Dockerfile
build_context: .
test_command: |
test -f app/api/v1/endpoints/access.py
grep -Fq 'api_router.include_router(access.router' app/api/v1/router.py
grep -Fq '@router.get("/projects"' app/api/v1/endpoints/meta.py
grep -Fq '@router.get("/projects/current"' app/api/v1/endpoints/project.py
grep -Fq '@router.post("/audit-events"' app/api/v1/endpoints/audit.py
deploy_service: backend
deploy_host: 192.168.1.114
secrets:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
GITEA_SERVER_URL: ${{ github.server_url }}
run: |
if [ -z "${REGISTRY_HOST:-}" ]; then
echo "Missing resolved environment value: REGISTRY_HOST"
exit 1
fi
if [ -z "${REGISTRY_USERNAME}" ]; then
echo "Missing required repository secret: REGISTRY_USERNAME"
exit 1
fi
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "Missing required repository secret: REGISTRY_PASSWORD"
exit 1
fi
echo "Registry username: ${REGISTRY_USERNAME}"
echo "Image target: ${IMAGE_REF}"
API_SERVER_URL="${GITEA_SERVER_URL%/}"
api_user="$(curl -fsS \
-H "Authorization: token ${REGISTRY_PASSWORD}" \
"${API_SERVER_URL}/api/v1/user" \
| sed -n 's/.*"login"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -n 1 || true)"
if [ -n "${api_user}" ]; then
echo "Registry token resolves to Gitea user: ${api_user}"
else
echo "Could not resolve Gitea user from REGISTRY_PASSWORD token; docker login may still use a password or a token without API access."
fi
echo "Logging into registry host: ${REGISTRY_HOST}"
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
--username "${REGISTRY_USERNAME}" \
--password-stdin
- name: Materialize runtime env file
env:
TJWATER_SERVER_ENV: ${{ secrets.TJWATER_SERVER_ENV }}
run: |
if [ -z "${TJWATER_SERVER_ENV}" ]; then
echo "Missing required repository secret: TJWATER_SERVER_ENV"
echo "Store the backend .env file content as a multiline Gitea repository secret named TJWATER_SERVER_ENV."
exit 1
fi
printf '%s\n' "${TJWATER_SERVER_ENV}" > .env
chmod 600 .env
required_env_keys=(
ENVIRONMENT
NETWORK_NAME
DB_NAME
DB_HOST
DB_PORT
DB_USER
DB_PASSWORD
TIMESCALEDB_DB_NAME
TIMESCALEDB_DB_HOST
TIMESCALEDB_DB_PORT
TIMESCALEDB_DB_USER
TIMESCALEDB_DB_PASSWORD
METADATA_DB_NAME
METADATA_DB_HOST
METADATA_DB_PORT
METADATA_DB_USER
METADATA_DB_PASSWORD
DATABASE_ENCRYPTION_KEY
)
missing_keys=()
for key in "${required_env_keys[@]}"; do
if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=" .env; then
missing_keys+=("$key")
fi
done
if [ "${#missing_keys[@]}" -gt 0 ]; then
echo "TJWATER_SERVER_ENV is missing required keys: ${missing_keys[*]}"
exit 1
fi
- name: Validate workspace
run: |
if [ ! -f ./Dockerfile ]; then
echo "Dockerfile not found in workspace. Repository checkout may have failed or produced an unexpected workspace."
exit 1
fi
- name: Build and Push Image
run: |
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
exit 1
fi
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
if [ "${IMAGE_TAG}" = "latest" ]; then
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:latest"
else
docker build \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
fi
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
webhook_url=$(echo "$webhook_url" | xargs)
if [ -z "$webhook_url" ]; then
echo "Missing required repository variable: DEPLOY_WEBHOOK_URL"
return 1
fi
if [ -z "$token" ]; then
echo "Missing required repository secret: DEPLOY_WEBHOOK_TOKEN"
return 1
fi
echo "[$label] Calling webhook: $webhook_url"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
+2 -4
View File
@@ -14,13 +14,11 @@ COPY requirements.txt .
RUN pip install --no-cache-dir uv
RUN uv pip install --system --no-cache-dir -r requirements.txt
# 将代码放入子目录 'app',将数据放入子目录 'db_inp'
# 这样临时文件默认会生成在 /app 下,而代码在 /app/app 下,实现了分离
# 本地数据目录和环境变量在运行时通过 Compose 挂载或注入,
# 不应进入镜像构建上下文。
COPY app ./app
RUN python -c "from pathlib import Path; from zipfile import ZipFile; model_dir = Path('app/algorithms/health/model'); zip_path = model_dir / 'my_survival_forest_model_quxi.zip'; joblib_name = 'my_survival_forest_model_quxi.joblib'; joblib_path = model_dir / joblib_name; assert zip_path.exists(), f'Model archive not found: {zip_path}'; archive = ZipFile(zip_path); archive.extract(joblib_name, model_dir); archive.close(); assert joblib_path.exists(), f'Model file not extracted: {joblib_path}'" && \
rm -f app/algorithms/health/model/my_survival_forest_model_quxi.zip
# COPY db_inp ./db_inp
COPY .env .
RUN mkdir -p db_inp temp data inp
# 设置 PYTHONPATH 以便 uvicorn 找到 app 模块
+4
View File
@@ -316,6 +316,7 @@ def flushing_analysis(
flushing_flow: float = 0,
scheme_name: str = None,
username: str | None = None,
valve_control: dict[str, dict] = None,
) -> None:
"""
管道冲洗模拟
@@ -323,6 +324,7 @@ def flushing_analysis(
:param modify_pattern_start_time: 模拟开始时间,格式为'2024-11-25T09:00:00+08:00'
:param modify_total_duration: 模拟总历时,秒
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param valve_control: dict中可分别指定阀门的status、setting和k
:param drainage_node_ID: 冲洗排放口所在节点ID
:param flushing_flow: 冲洗水量,传入参数单位为m3/h
:param scheme_name: 方案名称
@@ -334,6 +336,7 @@ def flushing_analysis(
scheme_detail: dict = {
"duration": modify_total_duration,
"valve_opening": modify_valve_opening,
"valve_control": valve_control,
"drainage_node_ID": drainage_node_ID,
"flushing_flow": flushing_flow,
}
@@ -450,6 +453,7 @@ def flushing_analysis(
modify_pattern_start_time=modify_pattern_start_time,
modify_total_duration=modify_total_duration,
modify_valve_opening=modify_valve_opening,
valve_control=valve_control,
scheme_type="flushing_analysis",
scheme_name=scheme_name,
)
+78 -7
View File
@@ -1,4 +1,4 @@
from typing import Any, List, Optional
from typing import Any, List, Literal, Optional
from datetime import datetime, timedelta
import json
import threading
@@ -302,12 +302,20 @@ async def valve_isolation_endpoint(
return result
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
@router.post("/flushing-analyses", response_class=PlainTextResponse, summary="冲洗分析(高级)", description="高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。")
async def fastapi_flushing_analysis(
network: str = Query(..., description="管网名称(或数据库名称)"),
start_time: str = Query(..., description="冲洗开始时间(ISO 8601格式)"),
valves: List[str] = Query(..., description="要开启的阀门ID列表"),
valves_k: List[float] = Query(..., description="对应各阀门的开度列表(0-1"),
valves: List[str] | None = Query(None, description="参与控制的阀门ID列表(可选)"),
valves_k: List[float] | None = Query(
None, description="对应各阀门的开度列表(0-1,可选,与valves同时提供)"
),
valve_statuses: List[Literal["OPEN", "CLOSED", "ACTIVE"]] | None = Query(
None, description="对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)"
),
valve_settings: List[str] | None = Query(
None, description="对应各阀门的设置值列表(ACTIVE状态下必填)"
),
drainage_node_ID: str = Query(..., description="排污节点ID"),
flush_flow: float = Query(0, description="冲洗流量(L/s),0表示自动计算"),
duration: int | None = Query(None, description="模拟持续时间(秒),默认900秒"),
@@ -319,8 +327,10 @@ async def fastapi_flushing_analysis(
- **network**: 管网名称(或数据库名称)
- **start_time**: 冲洗开始时间
- **valves**: 要开启的阀门ID列表
- **valves_k**: 各阀门的开度列表(0-1,与valves对应
- **valves**: 参与控制的阀门ID列表(可选)
- **valves_k**: 各阀门的开度列表(0-1,可选,与valves同时提供
- **valve_statuses**: 各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)
- **valve_settings**: 各阀门的设置值列表(ACTIVE状态下必填)
- **drainage_node_ID**: 排污节点ID
- **flush_flow**: 冲洗流量(L/s
- **duration**: 模拟持续时间(秒,可选,默认900)
@@ -328,14 +338,75 @@ async def fastapi_flushing_analysis(
支持多阀联合冲洗操作。
"""
valve_opening = None
valve_control = None
if valve_statuses is not None and valves_k is not None:
raise HTTPException(
status_code=422,
detail="valve_statuses 和 valves_k 不能同时提供",
)
if valve_settings is not None and valve_statuses is None:
raise HTTPException(
status_code=422,
detail="valve_settings 必须与 valve_statuses 同时提供",
)
if valves is None:
if (
valves_k is not None
or valve_statuses is not None
or valve_settings is not None
):
raise HTTPException(
status_code=422,
detail="阀门控制参数必须与 valves 同时提供",
)
elif valve_statuses is not None:
if len(valves) != len(valve_statuses):
raise HTTPException(
status_code=422, detail="valves 和 valve_statuses 的数量必须一致"
)
if valve_settings is not None and len(valves) != len(valve_settings):
raise HTTPException(
status_code=422, detail="valves 和 valve_settings 的数量必须一致"
)
settings = valve_settings or [""] * len(valves)
valve_control = {}
for valve_id, raw_status, raw_setting in zip(
valves, valve_statuses, settings
):
status = raw_status
setting = raw_setting.strip()
if status == "ACTIVE" and not setting:
raise HTTPException(
status_code=422,
detail=f"ACTIVE 状态的阀门 {valve_id} 必须提供设置值",
)
control: dict[str, str] = {"status": status}
if status == "ACTIVE":
control["setting"] = setting
valve_control[valve_id] = control
elif valves_k is not None:
if len(valves) != len(valves_k):
raise HTTPException(
status_code=422, detail="valves 和 valves_k 的数量必须一致"
)
valve_opening = {
valve_id: float(valves_k[idx]) for idx, valve_id in enumerate(valves)
valve_id: float(valve_k)
for valve_id, valve_k in zip(valves, valves_k)
}
else:
raise HTTPException(
status_code=422,
detail="提供 valves 时必须同时提供 valve_statuses 或 valves_k",
)
result = flushing_analysis(
name=network,
modify_pattern_start_time=start_time,
modify_total_duration=duration or 900,
modify_valve_opening=valve_opening,
valve_control=valve_control,
drainage_node_ID=drainage_node_ID,
flushing_flow=flush_flow,
scheme_name=scheme_name,
+29 -2
View File
@@ -686,6 +686,28 @@ def get_history_pattern_info(project_name, pattern_name):
return flow_list, factor_list
def _apply_valve_control(
project_name: str, valve_control: dict[str, dict]
) -> None:
"""Apply explicit valve status, setting, and opening controls."""
for valve_name, control in valve_control.items():
valve_status = get_status(project_name, valve_name)
if "status" in control:
valve_status["status"] = control["status"]
if "setting" in control:
valve_status["setting"] = control["setting"]
if "k" in control:
valve_k = control["k"]
if valve_k == 0:
valve_status["status"] = "CLOSED"
else:
valve_status["setting"] = 0.1036 * pow(valve_k, -3.105)
cs = ChangeSet()
cs.append(valve_status)
set_status(project_name, cs)
# 2025/01/11
def run_simulation(
name: str,
@@ -701,6 +723,7 @@ def run_simulation(
modify_valve_opening: dict[str, float] = None,
scheme_type: str = None,
scheme_name: str = None,
valve_control: dict[str, dict] = None,
) -> None:
"""
传入需要修改的参数,改变数据库中对应位置的值,然后计算,返回结果
@@ -715,6 +738,7 @@ def run_simulation(
:param modify_fixed_pump_pattern: dict中包含多个水泵模式,str为工频水泵的id,list为修改后的pattern
:param modify_variable_pump_pattern: dict中包含多个水泵模式,str为变频水泵的id,list为修改后的pattern
:param modify_valve_opening: dict中包含多个阀门开启度,str为阀门的id,float为修改后的阀门开启度
:param valve_control: dict中可分别指定阀门的status、setting和k;存在时优先于modify_valve_opening
:param scheme_type: 模拟方案类型
:param scheme_name:模拟方案名称
:return:
@@ -1200,8 +1224,11 @@ def run_simulation(
cs = ChangeSet()
cs.append(pump_pattern)
set_pattern(name_c, cs)
# 修改阀门(valve)的状态setting和status
if modify_valve_opening:
# 显式阀门控制沿用 run_simulation_ex 的处理顺序和覆盖规则。
if valve_control is not None:
_apply_valve_control(name_c, valve_control)
# 保留原开度参数逻辑,兼容现有方案调用。
elif modify_valve_opening:
for valve_name in modify_valve_opening.keys():
if not np.isnan(modify_valve_opening[valve_name]):
valve_status = get_status(name_c, valve_name)
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "b003a57c9b8c1a041b644363ef58afb8bfcede1fe076ce48a190d2a1ac915e3f"
"sha256": "9ad12d3cd789fd42c341faec5b859d76bceeabc74399e3991e62ace1129e69a2"
}
}
}
+70 -9
View File
@@ -11038,7 +11038,7 @@
},
"/api/v1/flushing-analyses": {
"post": {
"description": "高级版本的冲洗分析,支持同时开启多个阀门进行冲洗,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
"description": "高级版本的冲洗分析,支持按状态和设置值控制多个可选阀门,指定排污节点,并设置固定的冲洗流量。返回纯文本格式的分析结果。",
"operationId": "post_flushing_analyses",
"parameters": [
{
@@ -11053,31 +11053,92 @@
}
},
{
"description": "要开启的阀门ID列表",
"description": "参与控制的阀门ID列表(可选)",
"in": "query",
"name": "valves",
"required": true,
"required": false,
"schema": {
"description": "要开启的阀门ID列表",
"anyOf": [
{
"items": {
"type": "string"
},
"title": "Valves",
"type": "array"
},
{
"type": "null"
}
],
"description": "参与控制的阀门ID列表(可选)",
"title": "Valves"
}
},
{
"description": "对应各阀门的开度列表(0-1",
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供",
"in": "query",
"name": "valves_k",
"required": true,
"required": false,
"schema": {
"description": "对应各阀门的开度列表(0-1",
"anyOf": [
{
"items": {
"type": "number"
},
"title": "Valves K",
"type": "array"
},
{
"type": "null"
}
],
"description": "对应各阀门的开度列表(0-1,可选,与valves同时提供)",
"title": "Valves K"
}
},
{
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
"in": "query",
"name": "valve_statuses",
"required": false,
"schema": {
"anyOf": [
{
"items": {
"enum": [
"OPEN",
"CLOSED",
"ACTIVE"
],
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"description": "对应各阀门的开关状态列表(OPEN、CLOSED或ACTIVE,可选)",
"title": "Valve Statuses"
}
},
{
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
"in": "query",
"name": "valve_settings",
"required": false,
"schema": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "null"
}
],
"description": "对应各阀门的设置值列表(ACTIVE状态下必填)",
"title": "Valve Settings"
}
},
{
+140
View File
@@ -383,6 +383,7 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
"modify_pattern_start_time": "2025-01-02T03:04:05+08:00",
"modify_total_duration": 900,
"modify_valve_opening": {"V1": 0.5},
"valve_control": None,
"drainage_node_ID": "N1",
"flushing_flow": 100.0,
"scheme_name": "flush_case_01",
@@ -390,6 +391,145 @@ def test_flushing_endpoint_passes_required_scheme_name(monkeypatch):
}
def test_flushing_endpoint_allows_omitting_valves(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured = {}
def fake_flushing_analysis(**kwargs):
captured.update(kwargs)
return "ok"
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"drainage_node_ID": "N1",
"scheme_name": "flush_without_valves",
},
)
assert response.status_code == 200
assert response.text == "ok"
assert captured["modify_valve_opening"] is None
assert captured["valve_control"] is None
assert captured["drainage_node_ID"] == "N1"
def test_flushing_endpoint_passes_explicit_valve_control(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured = {}
def fake_flushing_analysis(**kwargs):
captured.update(kwargs)
return "ok"
monkeypatch.setattr(module, "flushing_analysis", fake_flushing_analysis)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params=[
("network", "demo"),
("start_time", "2025-01-02T03:04:05+08:00"),
("valves", "V1"),
("valves", "V2"),
("valve_statuses", "ACTIVE"),
("valve_statuses", "CLOSED"),
("valve_settings", "2.5"),
("valve_settings", ""),
("drainage_node_ID", "N1"),
("scheme_name", "flush_with_valve_control"),
],
)
assert response.status_code == 200
assert captured["modify_valve_opening"] is None
assert captured["valve_control"] == {
"V1": {"status": "ACTIVE", "setting": "2.5"},
"V2": {"status": "CLOSED"},
}
def test_flushing_endpoint_requires_setting_for_active_valve(monkeypatch):
module = _load_simulation_module(monkeypatch)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": "V1",
"valve_statuses": "ACTIVE",
"drainage_node_ID": "N1",
"scheme_name": "flush_without_active_setting",
},
)
assert response.status_code == 422
def test_flushing_endpoint_rejects_mixed_valve_control_modes(monkeypatch):
module = _load_simulation_module(monkeypatch)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": "V1",
"valves_k": 0.5,
"valve_statuses": "ACTIVE",
"valve_settings": "2.5",
"drainage_node_ID": "N1",
"scheme_name": "flush_with_mixed_controls",
},
)
assert response.status_code == 422
def test_flushing_endpoint_rejects_settings_without_statuses(monkeypatch):
module = _load_simulation_module(monkeypatch)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"valves": "V1",
"valves_k": 0.5,
"valve_settings": "2.5",
"drainage_node_ID": "N1",
"scheme_name": "flush_with_orphan_settings",
},
)
assert response.status_code == 422
def test_flushing_endpoint_requires_drainage_node(monkeypatch):
module = _load_simulation_module(monkeypatch)
client = _build_authenticated_client(module)
response = client.post(
"/api/v1/flushing-analyses",
params={
"network": "demo",
"start_time": "2025-01-02T03:04:05+08:00",
"scheme_name": "flush_without_drainage_node",
},
)
assert response.status_code == 422
def test_contaminant_endpoint_passes_current_username(monkeypatch):
module = _load_simulation_module(monkeypatch)
captured = {}
@@ -1,3 +1,4 @@
import inspect
import json
from datetime import timedelta
@@ -7,6 +8,70 @@ from app.infra.db.timescaledb.repositories.scheme import SchemeRepository
from app.services.time_api import parse_utc_time
def test_run_simulation_exposes_explicit_valve_control():
from app.services import simulation
parameters = inspect.signature(simulation.run_simulation).parameters
assert "valve_control" in parameters
def test_apply_valve_control_matches_run_simulation_ex_semantics(monkeypatch):
from app.services import simulation
updates: dict[str, dict] = {}
monkeypatch.setattr(
simulation,
"get_status",
lambda project_name, valve_name: {
"link": valve_name,
"status": "OPEN",
"setting": 1.0,
},
)
monkeypatch.setattr(
simulation,
"set_status",
lambda project_name, changeset: updates.update(
{
changeset.operations[0]["link"]: changeset.operations[0].copy()
}
),
)
simulation._apply_valve_control(
"demo",
{
"V-status": {"status": "ACTIVE"},
"V-setting": {"setting": 2.5},
"V-closed": {"status": "ACTIVE", "setting": 9.0, "k": 0},
"V-k": {"status": "ACTIVE", "setting": 9.0, "k": 0.5},
},
)
assert updates["V-status"] == {
"link": "V-status",
"status": "ACTIVE",
"setting": 1.0,
}
assert updates["V-setting"] == {
"link": "V-setting",
"status": "OPEN",
"setting": 2.5,
}
assert updates["V-closed"] == {
"link": "V-closed",
"status": "CLOSED",
"setting": 9.0,
}
assert updates["V-k"] == {
"link": "V-k",
"status": "ACTIVE",
"setting": 0.1036 * pow(0.5, -3.105),
}
def _node_result(periods: int) -> list[dict]:
return [
{