This commit is contained in:
2026-05-08 15:16:04 +08:00
parent 042b626ccb
commit 507fb9789a
71 changed files with 14944 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Application service helpers."""
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
FUEL_FACTORS: list[tuple[str, Decimal]] = [
("HTPB", Decimal("0.00177")),
("Solid", Decimal("0.00178")),
("PBAN", Decimal("0.001772")),
("Kerolox", Decimal("0.0010290673")),
("Hydrolox", Decimal("0.00036235886")),
("Methalox", Decimal("0.000845043157")),
("Hydrazine", Decimal("0.001004")),
("MMH/NTO", Decimal("0.00116557")),
]
FUEL_DENSITY_T_PER_LITER = dict(FUEL_FACTORS)
VALUE_STEP = Decimal("0.000001")
def list_fuel_factors() -> list[dict[str, float | str]]:
return [
{"fuel": fuel, "tonnes_per_liter": _to_float(factor)}
for fuel, factor in FUEL_FACTORS
]
def convert_value(mode: str, raw_value: object) -> dict[str, object]:
value = _parse_positive_decimal(raw_value)
normalized_mode = mode.strip().lower()
if normalized_mode == "volume":
results = [
{
"fuel": fuel,
"value": _to_float(value * factor),
"unit": "t",
}
for fuel, factor in FUEL_FACTORS
]
return {
"mode": normalized_mode,
"input_value": _to_float(value),
"input_unit": "L",
"results": results,
}
if normalized_mode == "mass":
results = [
{
"fuel": fuel,
"value": _to_float(value / factor),
"unit": "L",
}
for fuel, factor in FUEL_FACTORS
]
return {
"mode": normalized_mode,
"input_value": _to_float(value),
"input_unit": "t",
"results": results,
}
raise ValueError("mode must be either 'volume' or 'mass'")
def _parse_positive_decimal(raw_value: object) -> Decimal:
try:
value = Decimal(str(raw_value))
except (InvalidOperation, ValueError, TypeError) as exc:
raise ValueError("value must be numeric") from exc
if value <= 0:
raise ValueError("value must be greater than zero")
return value
def _to_float(value: Decimal) -> float:
return float(value.quantize(VALUE_STEP, rounding=ROUND_HALF_UP))
+278
View File
@@ -0,0 +1,278 @@
from __future__ import annotations
import re
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from openpyxl import load_workbook
from sqlalchemy import delete, select
from app.extensions import db
from app.models import Asset, AssetLogEntry
EVENT_LINE_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+(.+)$")
LOG_BOOK_IGNORED_SHEETS = {"Overview", "Model"}
BODY_LOCATION_KEYWORDS = [
("earth-sun l2", "Earth-Sun L2"),
("earth-sun lagrange point 2", "Earth-Sun L2"),
("star port", "Star Port"),
("iapetus", "Iapetus"),
("callisto", "Callisto"),
("ganymede", "Ganymede"),
("europa", "Europa"),
("jupiter", "Jupiter"),
("saturn", "Saturn"),
("neptune", "Neptune"),
("mars", "Mars"),
("venus", "Venus"),
("mercury", "Mercury"),
("lunar orbit", "Lunar Orbit"),
("leo", "LEO"),
("earth orbit", "Earth"),
("earth", "Earth"),
("sun", "Sun"),
]
LOG_BOOK_ASSET_SPECS = {
"ST-01": {
"name": "万星源号 ST-01",
"asset_type": "Exploration Mothership",
"program": "Stellaria",
"home_region": "LEO",
"note": "First Class of Stellaria",
},
"XH-01": {
"name": "羲和号 XH-01",
"asset_type": "Exploration Mothership",
"program": "羲和计划",
"home_region": "LEO",
"note": "羲和计划首舰",
},
}
@dataclass
class LogBookSheetSummary:
sheet_name: str
asset_name: str
state_entries: int = 0
event_entries: int = 0
def to_dict(self) -> dict[str, object]:
return {
"sheet_name": self.sheet_name,
"asset_name": self.asset_name,
"state_entries": self.state_entries,
"event_entries": self.event_entries,
}
@dataclass
class LogBookImportSummary:
workbook: str
imported_assets: list[LogBookSheetSummary] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
return {
"workbook": self.workbook,
"imported_assets": [item.to_dict() for item in self.imported_assets],
}
def import_log_book_data(workbook_path: str | Path) -> LogBookImportSummary:
path = Path(workbook_path)
if not path.exists():
raise FileNotFoundError(f"Workbook not found: {path}")
workbook = load_workbook(path, data_only=True, read_only=True)
summary = LogBookImportSummary(workbook=path.name)
try:
for sheet_name in _iter_importable_sheet_names(workbook.sheetnames):
asset_spec = _resolve_asset_spec(sheet_name)
sheet_summary = _import_sheet(workbook[sheet_name], sheet_name, asset_spec)
summary.imported_assets.append(sheet_summary)
db.session.commit()
except Exception:
db.session.rollback()
raise
return summary
def _iter_importable_sheet_names(sheet_names: list[str]) -> list[str]:
return [sheet_name for sheet_name in sheet_names if sheet_name not in LOG_BOOK_IGNORED_SHEETS]
def _resolve_asset_spec(sheet_name: str) -> dict[str, str | None]:
asset_spec: dict[str, str | None] = {
"name": sheet_name,
"asset_type": "Vehicle",
"program": None,
"home_region": None,
"note": f"Imported from log_book.xlsx sheet {sheet_name}.",
}
asset_spec.update(LOG_BOOK_ASSET_SPECS.get(sheet_name, {}))
return asset_spec
def _import_sheet(worksheet: object, sheet_name: str, asset_spec: dict[str, str]) -> LogBookSheetSummary:
asset = db.session.execute(select(Asset).where(Asset.name == asset_spec["name"])).scalar_one_or_none()
if asset is None:
asset = Asset(
name=asset_spec["name"],
asset_type=asset_spec["asset_type"],
program=asset_spec["program"],
home_region=asset_spec["home_region"],
note=asset_spec["note"],
)
db.session.add(asset)
db.session.flush()
asset.asset_type = asset_spec["asset_type"]
asset.program = asset_spec["program"]
asset.home_region = asset_spec["home_region"]
asset.note = asset_spec["note"]
db.session.execute(delete(AssetLogEntry).where(AssetLogEntry.asset_id == asset.id))
db.session.flush()
summary = LogBookSheetSummary(sheet_name=sheet_name, asset_name=asset.name)
for row in worksheet.iter_rows(min_row=2, values_only=True):
if not any(value not in (None, "") for value in row):
continue
log_name = _clean_text(row[0])
start_at = _coerce_datetime(row[1])
end_at = _coerce_datetime(row[2])
mission_detail = _clean_text(row[3])
sub_log = _clean_text(row[4])
explicit_location = _clean_text(row[7])
inferred_location = _infer_location(log_name, mission_detail, sub_log, explicit_location)
if log_name is None or start_at is None:
continue
db.session.add(
AssetLogEntry(
asset_id=asset.id,
entry_kind="state",
title=log_name,
state_label=log_name,
mission_label=log_name,
location=inferred_location,
start_at=start_at,
end_at=end_at,
summary=mission_detail,
note=sub_log,
)
)
summary.state_entries += 1
return summary
def _clean_text(value: object) -> str | None:
if value in (None, ""):
return None
cleaned = str(value).strip()
return cleaned or None
def _coerce_datetime(value: object) -> datetime | None:
if value in (None, ""):
return None
if isinstance(value, datetime):
normalized = value
else:
normalized = datetime.fromisoformat(str(value))
if normalized.tzinfo is None:
return normalized.replace(tzinfo=timezone.utc)
return normalized.astimezone(timezone.utc)
def _infer_location(
log_name: str | None,
mission_detail: str | None,
sub_log: str | None,
explicit_location: str | None,
) -> str | None:
if explicit_location:
return explicit_location
haystacks = [
(log_name or "").casefold(),
(mission_detail or "").casefold(),
(sub_log or "").casefold(),
]
for keyword, location in BODY_LOCATION_KEYWORDS:
if any(keyword in haystack for haystack in haystacks):
return location
if mission_detail and "transfer" in mission_detail.casefold():
return "Sun"
if log_name and "maintenance" in log_name.casefold():
return "LEO"
if log_name and "construction" in log_name.casefold():
return "LEO"
return None
def _infer_event_location(description: str, default_location: str | None) -> str | None:
lowered = description.casefold()
for keyword, location in BODY_LOCATION_KEYWORDS:
if keyword in lowered:
return location
return default_location
def _parse_sub_log_events(
asset_id: object,
parent_title: str,
parent_start_at: datetime,
parent_end_at: datetime | None,
default_location: str | None,
sub_log: str | None,
) -> list[AssetLogEntry]:
if sub_log is None:
return []
events = []
for raw_line in sub_log.splitlines():
line = raw_line.strip()
if not line:
continue
match = EVENT_LINE_PATTERN.match(line)
if match is None:
continue
event_at = _coerce_datetime(match.group(1))
if event_at is None:
continue
# Skip obvious workbook typos that fall far outside the parent interval.
if event_at < parent_start_at.replace(year=parent_start_at.year - 1):
continue
if parent_end_at is not None and event_at > parent_end_at.replace(year=parent_end_at.year + 1):
continue
description = match.group(2).strip()
events.append(
AssetLogEntry(
asset_id=asset_id,
entry_kind="event",
title=description,
state_label=None,
mission_label=parent_title,
location=_infer_event_location(description, default_location),
start_at=event_at,
end_at=None,
summary=parent_title,
note=line,
)
)
return events
+316
View File
@@ -0,0 +1,316 @@
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
from pathlib import Path
from openpyxl import load_workbook
from sqlalchemy import delete, inspect
from app.extensions import db
from app.models import CommunicationPart, EngineFamily, EngineVariant, TankSpec, VehicleCost
@dataclass
class WorkbookImportSummary:
workbook: str
replaced_existing: bool
imported_rows: dict[str, int] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
return {
"workbook": self.workbook,
"replaced_existing": self.replaced_existing,
"imported_rows": self.imported_rows,
"warnings": self.warnings,
}
def import_workbook_data(
workbook_path: str | Path, replace_existing: bool = False
) -> WorkbookImportSummary:
path = Path(workbook_path)
if not path.exists():
raise FileNotFoundError(f"Workbook not found: {path}")
_assert_schema_ready()
summary = WorkbookImportSummary(workbook=path.name, replaced_existing=replace_existing)
workbook = load_workbook(path, data_only=True, read_only=True)
try:
if replace_existing:
_clear_import_tables()
elif _database_has_data():
raise ValueError("数据库已有数据。若要重导,请使用 --replace。")
family_count, variant_count = _import_engines(workbook["Engine Database"], summary)
communication_count = _import_communication_parts(workbook["Communication"])
tank_count = _import_tank_specs(workbook["Tank Chart"])
vehicle_cost_count = _import_vehicle_costs(workbook["KSP Vehicle Cost"])
db.session.commit()
except Exception:
db.session.rollback()
raise
summary.imported_rows = {
"engine_families": family_count,
"engine_variants": variant_count,
"communication_parts": communication_count,
"tank_specs": tank_count,
"vehicle_costs": vehicle_cost_count,
}
return summary
def _assert_schema_ready() -> None:
existing_tables = set(inspect(db.engine).get_table_names())
required_tables = {
"engine_families",
"engine_variants",
"communication_parts",
"tank_specs",
"vehicle_costs",
}
missing_tables = sorted(required_tables - existing_tables)
if missing_tables:
missing = ", ".join(missing_tables)
raise ValueError(f"数据库缺少表:{missing}。请先执行数据库迁移。")
def _database_has_data() -> bool:
for model in (EngineFamily, EngineVariant, CommunicationPart, TankSpec, VehicleCost):
if db.session.query(model).first() is not None:
return True
return False
def _clear_import_tables() -> None:
for model in (EngineVariant, EngineFamily, CommunicationPart, TankSpec, VehicleCost):
db.session.execute(delete(model))
db.session.flush()
def _import_engines(worksheet: object, summary: WorkbookImportSummary) -> tuple[int, int]:
families_by_name: dict[str, EngineFamily] = {}
variant_count = 0
for row_number, row in enumerate(worksheet.iter_rows(min_row=2, values_only=True), start=2):
if not _row_has_values(row):
continue
engine_name = _text(row[0])
if engine_name is None:
summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Engine,已跳过。")
continue
family = families_by_name.get(engine_name)
if family is None:
family = EngineFamily(
engine_name=engine_name,
part_name=_text(row[18]),
cycle=_text(row[2]),
size_m=_decimal(row[12]),
entry_cost=_integer(row[14]),
)
families_by_name[engine_name] = family
db.session.add(family)
else:
family.part_name = family.part_name or _text(row[18])
family.cycle = family.cycle or _text(row[2])
family.size_m = family.size_m or _decimal(row[12])
family.entry_cost = family.entry_cost or _integer(row[14])
fuel_type = _text(row[1])
if fuel_type is None:
summary.warnings.append(f"Engine Database 第 {row_number} 行缺少 Fuel Type,已跳过。")
continue
min_thrust = _decimal(row[6])
max_thrust = _decimal(row[7])
mass = _decimal(row[8])
variant = EngineVariant(
family=family,
fuel_type=fuel_type,
work_env=_text(row[3]),
sl_isp=_decimal(row[4]),
vac_isp=_decimal(row[5]),
min_thrust_kn=min_thrust,
max_thrust_kn=max_thrust,
mass_t=mass,
twr=_decimal(row[9]) or _calculate_twr(max_thrust, mass),
throttle_ratio=_decimal(row[10]) or _calculate_throttle_ratio(min_thrust, max_thrust),
tvc_deg=_decimal(row[11]),
price_kd=_integer(row[13]),
ignitions=_integer(row[15]),
has_unlimited_ignitions=_integer(row[15]) is None,
mod_source=_text(row[16]),
engine_note=_text(row[17]),
config_name=_text(row[19]),
tech_required=_text(row[20]),
config_note=_text(row[21]),
source_sheet_row=row_number,
)
db.session.add(variant)
variant_count += 1
return len(families_by_name), variant_count
def _import_communication_parts(worksheet: object) -> int:
count = 0
for row in worksheet.iter_rows(min_row=2, values_only=True):
if not _row_has_values(row):
continue
part_name = _text(row[0])
if part_name is None:
continue
db.session.add(
CommunicationPart(
part_name=part_name,
display_name=_text(row[1]),
mass_t=_decimal(row[2]),
is_active=_boolean(row[3]),
is_deployable=_boolean(row[4]),
antenna_type=_text(row[5]),
deployed_diameter_m=_decimal(row[6]),
range_raw=_decimal(row[7]),
range_km=_decimal(row[8]),
range_au=_decimal(row[9]),
range_light_year=_decimal(row[10]),
angle_deg=_decimal(row[11]),
speed=_decimal(row[12]),
idle_power_watt=_decimal(row[13]),
idle_power_text=_text(row[14]),
transmitting_power_watt=_decimal(row[15]),
transmitting_power_text=_text(row[16]),
source=_text(row[17]),
entry_cost=_integer(row[18]),
cost=_integer(row[19]),
description=_text(row[20]),
rescale_factor=_decimal(row[21]),
tweakscale=_text(row[22]),
is_feeder=_boolean(row[23]),
tech_required=_text(row[24]),
note=_text(row[25]),
)
)
count += 1
return count
def _import_tank_specs(worksheet: object) -> int:
count = 0
for row in worksheet.iter_rows(min_row=2, values_only=True):
if not _row_has_values(row):
continue
tank_name = _text(row[0])
if tank_name is None:
continue
db.session.add(
TankSpec(
tank_name=tank_name,
fuel_type=_text(row[1]),
dry_mass_t=_decimal(row[2]),
fuel_mass_t=_decimal(row[3]),
wet_mass_t=_decimal(row[4]),
tank_volume_l=_decimal(row[5]),
mass_ratio=_decimal(row[6]),
kiloliters_per_ton=_decimal(row[7]),
vehicle_name=_text(row[8]),
source=_text(row[9]),
note=_text(row[10]),
)
)
count += 1
return count
def _import_vehicle_costs(worksheet: object) -> int:
count = 0
for row in worksheet.iter_rows(min_row=2, values_only=True):
if not _row_has_values(row):
continue
vehicle_name = _text(row[0])
if vehicle_name is None:
continue
db.session.add(
VehicleCost(
vehicle_name=vehicle_name,
launch_price=_integer(row[1]),
source=_text(row[2]),
)
)
count += 1
return count
def _row_has_values(row: tuple[object, ...]) -> bool:
return any(value not in (None, "") for value in row)
def _text(value: object) -> str | None:
if value in (None, ""):
return None
cleaned = str(value).strip()
return cleaned or None
def _decimal(value: object) -> Decimal | None:
if value in (None, ""):
return None
if isinstance(value, Decimal):
return value
try:
return Decimal(str(value))
except (InvalidOperation, ValueError, TypeError):
return None
def _integer(value: object) -> int | None:
decimal_value = _decimal(value)
if decimal_value is None:
return None
return int(decimal_value.to_integral_value())
def _boolean(value: object) -> bool | None:
if value in (None, ""):
return None
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y"}:
return True
if text in {"false", "0", "no", "n"}:
return False
return None
def _calculate_twr(max_thrust: Decimal | None, mass: Decimal | None) -> Decimal | None:
if max_thrust is None or mass in (None, Decimal("0")):
return None
return max_thrust / Decimal("9.80665") / mass
def _calculate_throttle_ratio(
min_thrust: Decimal | None, max_thrust: Decimal | None
) -> Decimal | None:
if min_thrust is None or max_thrust in (None, Decimal("0")):
return None
return min_thrust / max_thrust
+138
View File
@@ -0,0 +1,138 @@
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from openpyxl import load_workbook
ENGINE_FIELD_INDICES = {
"fuel_type": 1,
"cycle": 2,
"work_env": 3,
"tvc": 11,
"size": 12,
"price_kd": 13,
"entry_cost": 14,
"classification": 16,
"note": 17,
"part_name": 18,
"config_name": 19,
}
def summarize_workbook(workbook_path: str | Path) -> dict[str, object]:
path = Path(workbook_path)
workbook = load_workbook(path, data_only=False)
sheet_row_counts = {
sheet_name: len(_non_empty_rows(workbook[sheet_name]))
for sheet_name in workbook.sheetnames
}
engine_sheet = workbook["Engine Database"]
engine_rows = _non_empty_rows(engine_sheet)
engine_headers = [
cell.value
for cell in next(engine_sheet.iter_rows(min_row=1, max_row=1))
if cell.value is not None
]
duplicate_engine_config = _sample_duplicates(
Counter((row[0], row[19]) for row in engine_rows if _present(row[0]))
)
duplicate_engine_config_with_fuel = _sample_duplicates(
Counter((row[0], row[19], row[1]) for row in engine_rows if _present(row[0]))
)
work_env_values = sorted(
{row[3] for row in engine_rows if _present(row[3])}, key=str
)
return {
"workbook": path.name,
"sheet_names": workbook.sheetnames,
"sheet_row_counts": sheet_row_counts,
"engine_headers": engine_headers,
"engine_work_env_values": work_env_values,
"candidate_key_conflicts": {
"engine_plus_config_name": {
"count": len(duplicate_engine_config),
"samples": duplicate_engine_config[:12],
},
"engine_plus_config_name_plus_fuel_type": {
"count": len(duplicate_engine_config_with_fuel),
"samples": duplicate_engine_config_with_fuel[:12],
},
},
"engine_field_variance": _field_variance(engine_rows),
"recommended_family_fields": [
"engine_name",
"part_name",
"cycle",
"size_m",
"entry_cost",
],
"recommended_variant_fields": [
"fuel_type",
"work_env",
"sl_isp",
"vac_isp",
"min_thrust_kn",
"max_thrust_kn",
"mass_t",
"twr",
"throttle_ratio",
"tvc_deg",
"price_kd",
"ignitions",
"has_unlimited_ignitions",
"mod_source",
"engine_note",
"config_name",
"tech_required",
"config_note",
],
}
def _field_variance(engine_rows: list[tuple[object, ...]]) -> dict[str, int]:
grouped_rows: dict[object, list[tuple[object, ...]]] = defaultdict(list)
for row in engine_rows:
grouped_rows[row[0]].append(row)
variance: dict[str, int] = {}
for field_name, index in ENGINE_FIELD_INDICES.items():
changing = 0
for rows in grouped_rows.values():
values = {row[index] for row in rows if _present(row[index])}
if len(values) > 1:
changing += 1
variance[field_name] = changing
return variance
def _sample_duplicates(counter: Counter[tuple[object, ...]]) -> list[dict[str, object]]:
samples: list[dict[str, object]] = []
for key, count in counter.items():
if count <= 1:
continue
record = {"count": count}
for index, value in enumerate(key):
record[f"key_{index + 1}"] = value
samples.append(record)
return samples
def _non_empty_rows(worksheet: object) -> list[tuple[object, ...]]:
return [
row
for row in worksheet.iter_rows(min_row=2, values_only=True)
if any(_present(value) for value in row)
]
def _present(value: object) -> bool:
return value not in (None, "")