import os from fastapi import FastAPI, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from app.services.data_service import data_service from app.services.count_converter import ( convert_count, get_available_standard_counts, get_available_ply_counts, nearest_standard_counts, parse_count_string, ) from app.services.reed_calculator import get_reed_range app = FastAPI(title="PDC Intelligence API", version="2.0.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.on_event("startup") async def startup_event() -> None: print("Loading data...") data_service.load_data() print("Warming up API caches...") data_service.get_dashboard_summary() data_service.get_filters() data_service.get_relativity_analytics() data_service.get_validation_report(sample_size=100, seed=42) print("Cache warming complete.") @app.get("/api/health") def health() -> dict: return data_service.get_health() @app.get("/api/dashboard") def dashboard() -> dict: return data_service.get_dashboard_summary() @app.get("/api/filters") def filters() -> dict: return data_service.get_filters() @app.get("/api/articles") def articles( page: int = Query(1, ge=1), limit: int = Query(25, ge=1, le=200), search: str = "", weave: str = "", blend: str = "", loom_type: str = "", dataset: str = "", count_band: str = "", ) -> dict: return data_service.get_articles( page, limit, search, weave, blend, loom_type, dataset, count_band, ) @app.get("/api/article/{master_article}") def article(master_article: str) -> dict: return data_service.get_article_detail(master_article) @app.get("/api/analytics/relativity") def relativity() -> dict: return data_service.get_relativity_analytics() @app.get("/api/process-flow") def process_flow() -> dict: return data_service.get_process_flow() @app.get("/api/documentation") def documentation() -> dict: return data_service.get_documentation() @app.get("/api/data-source/audit") def data_source_audit() -> dict: return data_service.get_data_source_audit() @app.get("/api/validation/report") def validation_report( sample_size: int = Query(250, ge=30, le=2000), seed: int = 42, ) -> dict: return data_service.get_validation_report(sample_size=sample_size, seed=seed) @app.post("/api/predictions/construction") def predict(payload: dict) -> dict: return data_service.predict_construction(payload) # ============== New endpoints ============== @app.get("/api/count/available") def available_counts() -> dict: return { "standard_ne": get_available_standard_counts(), "ply_yarns": get_available_ply_counts(), } @app.get("/api/count/convert") def count_convert( value: float = Query(...), from_unit: str = Query("ne"), to_unit: str = Query("denier"), ) -> dict: result = convert_count(value, from_unit, to_unit) return { "input": {"value": value, "unit": from_unit}, "output": {"value": result, "unit": to_unit}, } @app.get("/api/count/nearest") def nearest_counts( value: float = Query(...), n: int = Query(3, ge=1, le=10), ) -> dict: nearest = nearest_standard_counts(value, n) return { "input_value": value, "nearest_standard_ne": nearest, } @app.get("/api/count/parse") def parse_count(count_string: str = Query(...)) -> dict: result = parse_count_string(count_string) if result is None: return {"error": f"Could not parse: {count_string}"} return {"input": count_string, "parsed": result} @app.get("/api/reed/ranges") def reed_ranges(loom_type: str = Query("airjet")) -> dict: return { "loom_type": loom_type, "reed_counts": get_reed_range(loom_type), "all_airjet": get_reed_range("airjet"), "all_rapier": get_reed_range("rapier"), } @app.post("/api/predictions/export-xml") def export_xml(payload: dict): import io import xml.etree.ElementTree as ET from fastapi.responses import Response rec = payload.get("recommendation", {}) inp = payload.get("input", {}) def gv(key: str, fallback=0): val = rec.get(key) if val: try: return round(float(val), 2) except (ValueError, TypeError): return fallback return fallback w_cnt = gv("warp_count", inp.get("normalized_warp_count") or inp.get("raw_warp_count") or 0) wt_cnt = gv("weft_count", inp.get("normalized_weft_count") or inp.get("raw_weft_count") or 0) reed = gv("reed_count", 0) e_dent = gv("ends_per_dent", 0) r_space = gv("reed_space", 0) g_epi = gv("greige_epi", 0) g_ppi = gv("greige_ppi", 0) f_epi = gv("finish_epi", 0) f_ppi = gv("finish_ppi", 0) f_width = gv("finish_width", 0) blend = str(inp.get("blend", "")) weave = str(inp.get("weave", "")) root = ET.Element("PenelopeDesigns", Version="v1.12") fabric = ET.SubElement(root, "Fabric", Name="construction") fabric.set("Folder1", "BA") fabric.set("Folder2", "DS") fabric.set("Folder3", "2026") yarns = ET.SubElement(fabric, "Yarns") for yname, count, ylabel in [ ("WARP_YARN", w_cnt, "WARP"), ("WEFT_YARN", wt_cnt, "WEFT"), ]: yarn = ET.SubElement(yarns, "Yarn", DataBaseId="0", Name=yname, Set="construction") ET.SubElement(yarn, "SecondName").text = ylabel ET.SubElement(yarn, "Count").text = f"{count} ne" if count else "" ET.SubElement(yarn, "Material").text = blend ends_total = int(g_epi * r_space * 0.0254 * 100) if g_epi and r_space else 0 warp = ET.SubElement(fabric, "Warp", NEndsRepeat=str(ends_total), NColorWays="1", NColors="1", NBeams="1") cw_warp = ET.SubElement(warp, "ColorWay", Name="A") ET.SubElement(cw_warp, "Yarn", Id="0", DataBaseId="0", Set="construction", Name="WARP_YARN", NEnds=str(ends_total)) weft = ET.SubElement(fabric, "Weft", NEndsRepeat="0", NColorWays="1", NColors="1") cw_weft = ET.SubElement(weft, "ColorWay", Name="A") ET.SubElement(cw_weft, "Yarn", Id="0", DataBaseId="0", Set="construction", Name="WEFT_YARN") ET.SubElement(fabric, "Weave", Name=f"{weave}.", Folder1="WEAVE", NEndsRepeat="0", NPicksRepeat="0") denting = ET.SubElement(fabric, "Denting", NDents=str(int(e_dent)), NEnds="4") ET.SubElement(denting, "Dents").text = f"{int(e_dent)}x{int(e_dent)}" if e_dent else "0" tech = ET.SubElement(fabric, "TechnicalData") reed_dens_cm = round(reed / 39.37, 1) if reed else 0 ET.SubElement(tech, "ReedDensity", Value=str(reed), Unit="inch", ValueCm=str(reed_dens_cm)) reed_width_cm = round(r_space * 2.54, 2) if r_space else 0 ET.SubElement(tech, "ReedWidth", Value=str(r_space), Unit="inch", ValueCM=str(reed_width_cm)) fin_wid_cm = round(f_width * 2.54, 2) if f_width else 0 ET.SubElement(tech, "FinishedWidth", Value=str(f_width), Unit="inch", ValueCM=str(fin_wid_cm)) ET.SubElement(tech, "FinishedWarpDensity", Value=str(f_epi), Unit="inch", ValueCm=str(round(f_epi / 39.37, 1))) ET.SubElement(tech, "WovenWarpDensity", Value=str(g_epi), Unit="inch", ValueCm=str(round(g_epi / 39.37, 1))) ET.SubElement(tech, "WeftDensity", Value=str(g_ppi), Unit="inch", ValueCm=str(round(g_ppi / 39.37, 1))) ET.SubElement(tech, "Composition", Warp=blend, Weft=blend, Total=blend) ET.SubElement(tech, "Comment", Weave=weave) tree = ET.ElementTree(root) ET.indent(tree, space=" ", level=0) out = io.BytesIO() tree.write(out, encoding="utf-8", xml_declaration=True) return Response( content=out.getvalue(), media_type="application/xml", headers={"Content-Disposition": "attachment; filename=construction.xml"}, ) # Serve Next.js static frontend FRONTEND_BUILD_DIR = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "frontend", "out" ) FRONTEND_BUILD_DIR = os.path.abspath(FRONTEND_BUILD_DIR) if os.path.isdir(FRONTEND_BUILD_DIR): app.mount( "/", StaticFiles(directory=FRONTEND_BUILD_DIR, html=True), name="frontend" ) else: @app.get("/") def serve_frontend(): return HTMLResponse( content=""" PDC Intelligence

PDC Intelligence

Backend is running. API available at /api/*

Available Endpoints:

""", status_code=200, )