diff --git a/docs/albero-analisi.md b/docs/albero-analisi.md new file mode 100644 index 0000000..0066fb5 --- /dev/null +++ b/docs/albero-analisi.md @@ -0,0 +1,663 @@ +# Albero delle analisi Periscope + +Versione codice **2.63.0** (`periscope/src/frontend/content/changelog.md`). Letto in `/Users/michelebigi/Development/periscope`. Costituzione senza eccezioni: via ≠ pad ≠ traccia ≠ zona; niente I/Z/mm inventati; skip se manca evidenza. **Non è DRC KiCad.** DRC = KiCad. Questo documento mappa l’analisi, non implementa. + +I test pytest stanno in `tests/datasheet/`, `tests/library/`, `tests/schematic/`, `tests/pcb/`, `tests/af_ai/` (stessi file di 2.62.1, cartelle di mestiere). I path `tests/test_*.py` sotto i nodi restano i nomi file. + +Due pipeline: + +1. **Schematico / review AI** — `MODE=run`, `services/pipeline.py` → `validate_design_async` (`validation.py`): estrazione datasheet, check deterministici, review IC DeepSeek. +2. **Esame PCB** — `MODE=pcb`, `services/pcb_pipeline.py` → `run_pcb_checks` (`pcb_checks.py`) + review AI PCB (`pcb_review.py` / `pcb_validation.py`). Non auto-place. + +I certifier USB-C / Ethernet / PoE girano su **entrambe** le pipeline (grafo, non geometria). DDR / CPU / FPGA / USB-PD sono la stessa qualità di finding ma partono da `run_pcb_checks` (grafo). Niente pezzo sul grafo → silenzio, non N/A. + +Nodi analisi: **108** (99 PE-* + 1 LED senza `rule_id` + 8 processo estrazione/AI). Test pytest citati sotto i nodi: nomi reali da `tests/`. + +--- + +``` +Periscope 2.62.1 +├── 1. Schematico / review AI (MODE=run) +│ ├── ingest (non è analisi: parser → grafo) +│ ├── estrazione datasheet +│ ├── check deterministici schema +│ ├── certifier di classe (condivisi con PCB) +│ └── review AI per-IC + post-pass +└── 2. Esame PCB (MODE=pcb) + ├── ingest layout (non è DRC) + ├── run_pcb_checks + │ ├── net / footprint + │ ├── placement vs layout_rules + │ ├── SI + linee HF (2.62.0 Fase A) + │ ├── DDR / CPU / FPGA (2.62.0) + │ ├── potenza / via / termico + │ ├── derating / timing / PI + │ ├── ESD / return / EMI / SPOF + │ ├── BOM ↔ PCB ↔ datasheet + │ └── resto Fase B (2.62.1) + ├── review AI PCB + └── tab antenna RF (non in run_pcb_checks) +``` + +--- + +## 1. Schematico / review AI (`MODE=run`) + +Orchestrazione: `pipeline.py` stadi `bom_parse` → `ic_extraction` → `simple_extraction` → `passive_extraction` → `graph_build` → `validation`. Check deterministici: `_run_deterministic_checks` in `validation.py`. Finding ID schema `PS-{ref}-{nnn}` (non `PCB-`). + +### 1.1 Ingest (prerequisito, non analisi) + +Parser e grafo non emettono PE-*. Skip MPN: `ic_mpn_skip_reason` (silenzio onesto, niente MPN inventato). + +- Test parser/grafo: `tests/test_bom_parser.py` (`test_parse_bom_uses_value_for_ic_when_mpn_column_empty`, `test_parse_bom_keeps_pnm_and_datasheet_url`); `tests/test_hubaudio_bom.py` (tutti e 4); `tests/test_netlist_parser.py` (tutti e 3); `tests/test_edif_parser.py` (14); `tests/test_kicad_parser.py` (17); `tests/test_kicad_project.py` (19); `tests/test_netlist_bundle.py` (9); `tests/test_functional_groups.py` (8). +- Test overlay parser: `tests/test_periscope_parsers_rewrite.py`, `tests/test_periscope_graph_rewrite.py`, `tests/test_periscope_models_rewrite.py`. + +### 1.2 Estrazione datasheet (processo) + +Skip se manca PDF o lo skill non ha numero. Libreria `library/extracted` condivisa schema+PCB. PCB review non rilegge il PDF (`pdf_path=None`). + +#### `extract-pintable` — skill `skills/extract-pintable/` + +Modulo: `services/datasheet_extract.py` + `llm/local_skill.py`. Analizza: pin table, package, taxonomy IC. + +- Skip se: niente PDF; IC già in libreria alla `model_version` minima. +- Test: `tests/test_native_datasheet_extract.py` (`test_coerce_abs_max_matches_inherited`, `test_native_extract_has_no_anthropic_import`); `tests/test_pdf_ingest.py` (6); `tests/test_deepseek_provider.py` (`test_local_skills_load`, `test_extract_pdf_text_includes_page_markers`, `test_wroom_rejects_bare_soc_pin1_ant`); `tests/test_periscope_taxonomy_skills_src.py` (`test_pintable_skill_mentions_save_tool_and_wroom`, `test_skills_dir_is_src_and_local`). + +#### `extract-specs` — skill `skills/extract-specs/` + +Analizza: specs discreti/connettori/cristalli; parametri fuori taxonomy → scartati. + +- Skip se: niente PDF o tipo non in `SIMPLE_TYPES`. +- Test: `tests/test_pdf_ingest.py` (`test_coerce_abs_max_keeps_valid_drops_junk`); `tests/test_layout_rules.py` (5 — `layout_rules` numerici, niente mm inventati). + +#### `extract-pattern` — skill `skills/extract-pattern/` + +Analizza: pattern MPN passivi (R/C/L/FB). + +- Skip se: MPN opaco; DigiKey/value fallback copre prima. +- Test: `tests/test_inductor_specs.py` (13, Z ferrite solo se il PDF dice N Ω @ freq); `tests/test_pcb_software_bugs.py` (`test_lqw_decoder_and_unknown_skip`, `test_fb1_blm_is_bead_not_dcr_resistor`). + +#### Resolve DigiKey / catalogo (esatto MPN) + +Modulo: `services/digikey.py`, `passive_from_distributor.py`, `passive_from_mpn.py`. Analizza: parametri catalogo per auto-resolve. Hit fuzzy rifiutati. + +- Skip se: MPN non esatto; API assente (fail-soft). +- Test: `tests/test_digikey_match.py` (3); `tests/test_passive_from_distributor.py` (7); `tests/test_passive_from_mpn.py` (7); `tests/test_datasheet_finder.py` (17); `tests/test_library.py` (5). + +#### Resolve da stringa Value (solo progetto) + +Modulo: `services/passive_from_value.py`. Specs value-derived **non** in libreria condivisa. + +- Skip se: stringa ambigua / placeholder. +- Test: `tests/test_passive_from_value.py` (8). + +### 1.3 Check deterministici schema + +Fail-soft per modulo. Senza evidenza: skip (lista vuota), non folklore. + +#### `PE-MUX-001` — `pin_mux_check.py` + +Analizza: funzione pin vs net (UART/SPI/I2C mux). +Skip se: `functions` vuote; link inter-device stesso peripheral; net opaco. + +- Test: `tests/test_pin_mux_check.py` — `test_real_defect_uart5_swapped_is_error`, `test_correct_assignment_no_finding`, `test_inter_device_same_peripheral_link_is_skipped`, `test_transceiver_peer_without_peripheral_still_fires`, `test_empty_functions_skipped`, `test_pin_exposes_peripheral_but_not_signal_no_complement`, `test_opaque_net_not_flagged`, `test_token_parser_and_normalizer`, `test_spi_legacy_net_names_match_modern_pin_functions`, `test_spi_controller_peripheral_names_are_synonyms`, `test_simple_project_uart0_nets_are_feasible_on_mspm0_pins`, `test_simple_project_uart0_swapped_on_mspm0_is_error`, `test_spi_genuine_infeasibility_still_fires_with_modern_names`, `test_finding_prints_full_raw_capability_list_and_intent_caveat`, `test_legacy_report_without_source_validates`. + +#### `PE-NC-001` — `nc_pin_check.py` + +Analizza: pin NC collegato a net attivo. +Skip se: niente pintable. + +- Test: `tests/test_nc_pin_check.py` — `test_nc_pin_on_active_net_warns`, `test_nc_pin_on_nc_net_silent`, `test_no_pintable_silent`. + +#### `PE-DEC-001` / `PE-DEC-002` — `passive_rail_check.py` `check_supply_decoupling` + +Analizza: condensatore sul net di alimentazione IC (001 presenza; 002 valore vs datasheet). +Skip se: net NC; pin enable strap; manca valore per 002. + +- Test: `tests/test_passive_rail_check.py` — `test_missing_decoupling_is_warning`, `test_cap_to_gnd_clears_decoupling`, `test_enable_strapped_to_rail_is_not_decoupling`, `test_nc_supply_net_is_skipped`, `test_ldo_vout_needs_cout`, `test_ldo_vout_100n_only_is_value_warning`, `test_vdd_100n_is_not_a_value_warning`, `test_ki_cad_voltage_prefix_is_power`, `test_fb_and_rn_prefixes`. + +#### `PE-I2C-001` / `PE-I2C-002` — `passive_rail_check.py` `check_i2c_pullups` + +Analizza: pull-up I2C (001 presenza; 002 valore vs banda). +Skip se: pin SDA è alias SPI; pull-up senza valore (002 non sizato); bus assente. + +- Test: `tests/test_passive_rail_check.py` — `test_i2c_missing_pullup`, `test_i2c_pullup_present`, `test_i2c_pullup_to_3v3_digital_typed_as_signal`, `test_spi_pin_alias_sda_is_not_i2c`, `test_i2c_from_slash_alias_in_pin_name`, `test_i2c_4k7_pullup_is_in_nxp_wide_band`, `test_i2c_100ohm_pullup_is_too_stiff`, `test_i2c_100k_pullup_is_too_weak`, `test_i2c_pullup_without_value_is_not_sized`. + +#### `PE-RST-001` / `PE-RST-002` — `passive_rail_check.py` `check_reset_pullups` + +Analizza: reset/enable pull (001 presenza; 002 verso vs datasheet). +Skip se: GPIO guida il reset. + +- Test: `tests/test_passive_rail_check.py` — `test_reset_no_finding_when_gpio_drives`, `test_reset_floating_is_warning`, `test_nrst_pulldown_is_warning`, `test_nrst_pullup_is_not_pulldown`. + +#### `PE-BOM-001` / `PE-BOM-002` — `bom_match_check.py` + +Analizza: MPN schema vs BOM (001 mismatch; 002 riga BOM orfana). +Skip se: mappa schematica vuota (PADS/EDIF senza properties). + +- Test: `tests/test_bom_match_check.py` — `test_matching_mpns_produce_no_findings`, `test_mpn_mismatch_is_error_ps_bom_001`, `test_orphan_bom_ref_is_warning`, `test_mpn_case_and_whitespace_are_not_a_mismatch`, `test_empty_bom_mpn_with_schematic_mpn_is_not_a_mismatch`, `test_empty_schematic_map_skips_check`, `test_legacy_design_graph_without_source_fields_still_validates`, `test_build_graph_kicad_mpn_mismatch_surfaces`. + +#### `PE-DNP-001` — `dnp_check.py` + +Analizza: DNP vs enable floating. +Skip se: colonna DNP assente. + +- Test: `tests/test_dnp_check.py` — `test_dnp_pull_leaves_enable_floating`, `test_fitted_pull_is_silent`, `test_no_dnp_column_skips_floating_enable`, `test_parse_bom_reads_dnp_and_skips_when_column_absent`. + +#### LED corrente — `led_current_check.py` (nessun `PE-*`) + +Analizza: I_f Ohm vs rating canale. Source `led_current_check`. +Skip se: niente `forward_current_*`; tensione rail sconosciuta (non si indovina). + +- Test: `tests/test_led_current_check.py` — `test_over_current_is_error`, `test_proper_resistor_no_finding`, `test_unknown_rail_no_error`, `test_no_rating_skipped`, `test_parse_resistance`. + +#### `PE-TH-001` / `PE-TH-002` / `PE-TH-003` — `thermal_check.py` + +Analizza: P = I_load × drop; Tj = Ta + P·θJA (001 INFO senza θJA o Tj ok; 002 WARNING Tj alto — **non in catalogo `finding_engine`**; 003 I²R shunt). +Skip se: manca I_load (Iout_max non è carico). + +- Test: `tests/test_thermal_check.py` — `test_ldo_without_theta_ja_is_info`, `test_iout_max_is_not_used_as_load`, `test_ldo_hot_tj_is_warning`, `test_shunt_over_rating_is_warning`, `test_led_resistor_within_rating_is_silent`. + +#### `PE-PWR-001` (schema) — `power_margin_check.py` + +Analizza: Σ IQ + I_load vs Iout regolatore; caduta su R serie. Source `power_margin_check`. +Skip se: manca I_load; IQ assente non è fail. + +- Test: `tests/test_power_margin_check.py` — `test_load_over_iout_max_is_ps_pwr_001`, `test_missing_iq_is_not_guessed_into_margin_fail`, `test_series_r_ir_drop_uses_i_load_not_trace`, `test_no_series_r_does_not_invent_trace_drop`. + +#### `PE-SEQ-001` — `sequencing_check.py` + +Analizza: PG → EN da note datasheet. +Skip se: niente spec `power_sequence`. + +- Test: `tests/test_sequencing_check.py` — `test_pg_not_tied_to_en_is_warning`, `test_pg_tied_to_en_is_silent`, `test_no_power_sequence_spec_skips_even_if_pg_open`. + +#### `PE-FLT-001` / `PE-FLT-002` / `PE-FLT-003` — `filter_check.py` + +Analizza: topologia filtro (001), pezzo (002), fc vs ADC / DCR ferrite (003). +Skip se: manca C; pull-up+decoupling non è filtro; DCR ferrite senza limite datasheet. + +- Test: `tests/test_filter_check.py` — `test_rc_reports_fc_info_without_adc_rate`, `test_rc_vs_adc_rate_is_warning`, `test_pullup_plus_decoupling_is_not_a_filter`, `test_missing_c_value_does_not_invent_fc_warning`, `test_pi_and_t_need_l_and_c`, `test_ferrite_dcr_warns_only_with_datasheet_limit`. + +#### `PE-XTAL-001` / `PE-XTAL-002` / `PE-XTAL-003` — `crystal_cl_check.py` + +Analizza: CL vs C1/C2 (001 coppia; 002 serie/stray; 003 drive). Cstray solo se in specs. +Skip se: niente CL in specs. + +- Test: `tests/test_crystal_cl_check.py` — `test_no_cl_in_specs_is_silent`, `test_series_above_cl_without_stray_warns`, `test_with_stray_mismatch_warns`, `test_simple_project_without_cl_silent`. + +#### `PE-LF-001` / `PE-LF-002` / `PE-LF-003` — `lifecycle.py` + +Analizza: Obsolete / NRND / RoHS non-compliant. +Skip se: catalogo assente. + +- Test: `tests/test_lifecycle_check.py` — `test_obsolete_is_warning_and_uses_distributor_replacement`, `test_nrnd_is_info`, `test_explicit_rohs_non_compliant_is_warning`, `test_active_is_silent`, `test_rohs_not_applicable_is_not_a_fail`, `test_missing_catalog_and_missing_substitute_are_not_guessed`. + +#### `PE-INT-001` — `internal_features_check.py` + +Analizza: ESD/pull interni vs net. +Skip se: `internal_features` vuoto (non si indovina open-drain). + +- Test: `tests/test_internal_features_check.py` — `test_listed_open_drain_without_pull_is_warning`, `test_listed_pin_with_pullup_is_silent`, `test_empty_features_does_not_guess_open_drain`. + +#### `PE-ESR-001` — `hf_coverage_check.py` + +Analizza: copertura HF/ESR (bulk vs 100 n). +Skip se: valore C sconosciuto. + +- Test: `tests/test_hf_coverage_check.py` — `test_bulk_only_is_info_ps_esr_001`, `test_bulk_plus_100n_is_silent`, `test_unknown_cap_value_is_not_guessed`. + +#### `PE-ERRATA-001` — `errata_check.py` + +Analizza: errata pubblicata vs pull sul pin. +Skip se: MPN sconosciuto o entry senza URL. + +- Test: `tests/test_errata_check.py` — `test_missing_errata_pullup_is_ps_errata_001`, `test_pullup_present_is_silent`, `test_unknown_mpn_and_url_less_entry_are_silent`. + +Suite deterministica end-to-end: `tests/test_validation_deterministic_seed.py` `test_deterministic_findings_seeded_and_not_reviewed`; `tests/test_eval_report.py` `test_simple_project_eval_matches_committed_golden`. + +### 1.4 Certifier di classe (grafo — schema **e** PCB) + +Moduli: `interface_class_check.py` (schema **e** PCB), `hf_bus_class.py` (2.62.0, solo `run_pcb_checks`), `usb_pd_check.py` (2.62.1, solo PCB). Niente geometria. Niente connettore/dispositivo → **silenzio**. + +#### USB-C — solo se receptacle USB-C sul grafo + +- **`PE-USBC-001`** classe USB2 vs USB3 da BOM/footprint/MPN/net. +- **`PE-USBC-002`** CC1 e CC2. +- **`PE-USBC-003`** Rd 5.1 kΩ ±10% o Rp 56/22/10 kΩ. Skip/INSUFFICIENT se R ignota o CC verso controller senza R. +- **`PE-USBC-004`** VBUS e GND. +- **`PE-USBC-005`** SuperSpeed solo se classe USB3; USB2 = N/A (non ERROR). + +Test: `tests/test_interface_class_check.py` — `test_no_connector_emits_nothing`, `test_usbc_rd_vbus_gnd_certified`, `test_usbc_missing_cc2_is_error`, `test_usbc_wrong_rd_is_error`, `test_usbc_cc_to_controller_without_r_is_insufficient_not_error`, `test_usbc_unknown_r_value_is_insufficient`, `test_usb3_class_without_ss_pairs_is_error`, `test_usb3_with_ss_pairs_is_certified`, `test_missing_vbus_is_error`, `test_simple_project_usbc_not_false_error`, `test_hubaudio_like_usbc_usb2_and_poe_magjack`, `test_usbc_vbus_does_not_invent_usb_500ma`, `test_does_not_use_layout_geometry`, `test_rule_catalog_shared_not_si`. + +#### Ethernet — solo se RJ45/MagJack + +- **`PE-ETH-001`** classe 10/100 vs GbE. Skip/INSUFFICIENT se velocità assente (non si inventa GbE). +- **`PE-ETH-002`** TX/RX (10/100) o quattro MDI (GbE). +- **`PE-ETH-003`** magnetics se GbE. +- **`PE-ETH-004`** Bob Smith 75 Ω: REVIEW (spesso in MagJack). + +Test: `tests/test_interface_class_check.py` — `test_ethernet_10_100_magjack_no_gbe_magnetics_error`, `test_gbe_without_magnetics_is_error`, `test_gbe_with_magnetics_is_certified`, `test_rj45_without_speed_is_insufficient_not_invented_gbe`. + +#### PoE — solo con evidenza PoE (RJ45 nudo = skip) + +- **`PE-POE-001`** evidenza PoE. +- **`PE-POE-002`** classe/tipo (802.3af/at/bt, Class n) — mai inventata. +- **`PE-POE-003`** magnetics/isolamento (MagJack o LAN transformer). +- **`PE-POE-004`** (2.62.1) tensione di isolamento solo da numero sourced. Skip senza V. + +Test: `tests/test_interface_class_check.py` — `test_bare_rj45_does_not_invent_poe`, `test_poe_with_evidence_requires_magnetics_isolation`, `test_poe_evidence_on_bare_jack_is_isolation_error`, `test_hubaudio_bom_has_usbc_and_poe_rj45_not_ddr`, `test_run_pcb_checks_skips_absent_interfaces`; `tests/test_pcb_phase_b.py` — `test_poe_isolation_skips_without_voltage_fact`. + +#### DDR — solo se DRAM sul grafo (2.62.0) + +- **`PE-DDR-001`** classe DRAM. +- **`PE-DDR-002`** CK, DQS, ≥8 DQ. +- **`PE-DDR-003`** VTT/VREF solo se quei net esistono (altrimenti skip, non VTT inventato). + +Test: `tests/test_hf_line_check.py` — `test_no_ddr_cpu_fpga_on_usb_only_graph`, `test_ddr_present_class_and_nets_skip_invented_vtt`, `test_rule_catalog_hf_ids`. + +#### CPU bus parallelo — solo se D/A/controllo esistono (2.62.0) + +- **`PE-CPU-001`** presenza bus parallelo. +- **`PE-CPU-002`** ≥8 D/DQ/AD. +- **`PE-CPU-003`** address o AD mux. +- **`PE-CPU-004`** CS/WE/OE (o FMC). + +Test: `tests/test_hf_line_check.py` — `test_cpu_parallel_bus_only_when_data_addr_control_exist`. + +#### FPGA — solo se FPGA sul grafo (2.62.0) + +- **`PE-FPGA-001`** classe FPGA. +- **`PE-FPGA-002`** flash di config se c’è un flash IC (altrimenti skip). +- **`PE-FPGA-003`** VCCINT/VCCIO solo se net nominati. + +Test: `tests/test_hf_line_check.py` — `test_fpga_only_when_present_config_flash_optional`. + +#### USB-PD contratto — `usb_pd_check.py` (2.62.1) + +- **`PE-PD-001`** controller PD sul grafo (FUSB302/TPS257/…). Non è USB2 Rd. PDO non inventati. + +Test: `tests/test_pcb_phase_b.py` — `test_usb_pd_skips_without_pd_controller`. + +### 1.5 Review AI per-IC + post-pass + +Moduli: `review_session.py`, `pcb_review.py` non entra qui. DeepSeek, PDF una volta per IC, fail-soft. Prompt tools grafo (`find_connected_components`, `get_net_for_pin`, `get_pintable`, `get_datasheet_excerpt`). Finding LLM = REVIEW, mai RULE ERROR (`finding_engine`). + +Skip se: niente MPN; niente PDF (`not_reviewed`); fingerprint invariato (`review_fingerprint.py`). + +- Review loop: `tests/test_native_review_loop.py` (5); `tests/test_validation_no_tool_recovery.py` `test_no_tool_calls_triggers_forced_submit_next_turn`; `tests/test_validation_concurrency.py` (3); `tests/test_validation_trace_log.py` (3); `tests/test_review_parse.py` (2); `tests/test_quote_verify.py` (5); `tests/test_finding_engine.py` (`test_llm_review_is_never_rule`, `test_review_and_risk_cannot_stay_error`, `test_insufficient_evidence_does_not_stay_error`); `tests/test_fase_b_checks.py` `test_llm_error_review_clamped_in_complete_finding`; `tests/test_review_fingerprint.py` (2); `tests/test_reprocess.py` (4); `tests/test_deepseek_provider.py` (`test_thinking_only_echoes_reasoning_content_on_follow_up` e roundtrip tool). +- Excerpt cross-IC: `tests/test_datasheet_excerpt_tool.py` (12); `tests/test_cache_breakpoint_limit.py` (3). +- **Normalize** (downgrade-only): `tests/test_normalize_findings.py` (14); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_normalize_cannot_promote_warning`. +- **Dedupe** cross-IC: `tests/test_dedupe_findings.py` (11); `tests/test_periscope_postpass_rewrite.py` `test_emmaforo_uart_dedupe_collapses_both_endpoints`. + +--- + +## 2. Esame PCB (`MODE=pcb`) + +Stadi: `ensure_graph` → `parse_pcb` → `classify` → `inventory` → **`run_pcb_checks`** → `ai_review` → `pcb_report.json`. ID finding `PCB-{ref}-{001}`. Mutex vs analisi schema e vs placement. Inventario net (`pcb_inventory.py`) è numeri, non finding. + +KiCad DRC **non** è un nodo Periscope. `tests/test_pcb_software_bugs.py` `test_drc_unconnected_items_count` e `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude` servono solo a **non** copiare il DRC. + +Suite orchestrazione: `tests/test_pcb_review.py` `test_run_pcb_checks_assigns_pcb_ids_and_recommendations`, `test_pcb_pipeline_imports_build_placement_plan`; `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`, `test_phase_b_rule_catalog`; `tests/test_interface_class_check.py` `test_merge_drops_duplicate_pcb_interface_findings`; `tests/test_pcb_sse_terminal.py` (4). + +### 2.1 Layout ingest (non DRC) + +`parsers_kicad_pcb.py` → `LayoutGraph`. Nessun default 1 oz. + +- Test: `tests/test_kicad_pcb.py` (14); `tests/test_pcb_via_not_pad.py` (geometria via≠pad); `tests/test_pcb_software_bugs.py` `test_kicad10_name_only_nets_fill_index`. + +### 2.2 Net / footprint — `pcb_net_match.py` + `pcb_checks._incomplete_layout_finding` + +#### `PE-LAY-001` + +Pad PCB su net diverso dallo schema (stesso pin). +Skip se: layout assente. + +- Test: `tests/test_pcb_review.py` — `test_pad_net_mismatch_is_pe_lay_001`, `test_hierarchy_plus_real_mismatch_keeps_only_real_pe_lay_001`. + +#### `PE-LAY-002` + +Ref in schema, nessun footprint (tranne #PWR). Distingue “manca sulla PCB”. + +- Test: `tests/test_pcb_review.py` `test_missing_footprint_is_pe_lay_002`; `tests/test_incomplete_pcb.py` `test_unplaced_ref_is_lay_002_not_invented_xy`. + +#### `PE-LAY-003` + +Stesso net, solo prefisso `/sheet/` KiCad (INFO, una volta). + +- Test: `tests/test_pcb_review.py` `test_kicad_hierarchy_slash_is_one_sync_finding_not_per_pad`. + +#### `PE-LAY-004` — `pcb_checks.py` (**non in catalogo `finding_engine`**) + +Pad net senza rame (track **o** via **o** zone). Skip NC `unconnected-(…)`. INFO INSUFFICIENT, non FAIL inventato. + +- Test: `tests/test_incomplete_pcb.py` `test_run_pcb_checks_on_partial_layout_does_not_invent_tracks`; `tests/test_pcb_software_bugs.py` — `test_lay004_zone_counts_as_copper`, `test_lay004_skips_unconnected_nc_and_matches_sheet_prefix`, `test_hubaudio_pcb_lay004_vs_drc_order_of_magnitude`. + +### 2.3 Placement vs `layout_rules` — `placement_check.py` + +Senza mm in `layout_rules`: skip. Niente 3 mm default. Distanza euclidea pad-pad **non** usata. + +#### `PE-PLC-001` + +Path rame (segmenti) vs `max_distance_mm`. + +- Test: `tests/test_placement_check.py` — `test_crystal_load_cap_beyond_max_distance_mm_is_ps_plc_001`, `test_crystal_load_cap_within_max_distance_mm_is_silent`, `test_track_path_longer_than_max_distance_mm_is_ps_plc_001`; `tests/test_pcb_review.py` `test_close_decoupling_has_no_plc_001`; `tests/test_placement_check.py` `test_simple_project_without_pcb_has_no_ps_plc`. + +#### `PE-PLC-002` + +Via in courtyard vs `min_via_count`. Skip senza vertici courtyard e senza min. + +- Test: `tests/test_placement_check.py` `test_via_count_is_calculated_from_courtyard_and_min_parameter` (geometria `_in_poly`; non emette ancora il finding end-to-end). + +#### `PE-PLC-003` + +`same_layer` decoupling. + +- Test: `tests/test_placement_check.py` — `test_same_layer_param_opposite_layers_is_ps_plc_003`, `test_same_layer_param_same_copper_is_silent`, `test_opposite_layers_without_same_layer_param_is_silent`, `test_opposite_layers_with_via_in_courtyard_is_silent`. + +#### `PE-PLC-004` + +Keepout: net estraneo nel courtyard. Non è DRC rame-pad. + +- Test: `tests/test_placement_check.py` — `test_keepout_foreign_track_in_courtyard_is_ps_plc_004`, `test_ic_courtyard_pad_nets_are_not_keepout_violations`, `test_keepout_own_net_in_courtyard_is_silent`, `test_keepout_without_courtyard_is_silent`. + +#### `PE-PLC-005` (**non in catalogo `finding_engine`**) + +Proximity non misurabile perché manca la **traccia**. + +- Test: `tests/test_placement_check.py` `test_unrouted_crystal_cap_is_insufficient_not_euclidean`. + +Schema runner omette i check layout: `tests/test_placement_check.py` `test_schema_deterministic_runner_omits_layout_checks`. + +### 2.4 SI — `si_check.py` + ImpedenceFinder + +Gate: `layout_rules` kind + `net_class`/quote sul **bus citato**. Skip I2C/GPIO/EN/CC/strap RC. Bus: USB2, USB3, ETH MDI, RGMII, SGMII, DDR3 CLK/DQS/DQ/ADDR, HDMI, PCIe, LVDS; 2.62.0 aggiunge MIPI e clock HF (`bus_class`); XTAL/OSCIN fuori. + +Z0: `impedance.py` / `impedance_traces.py`. CPWG → errore, non numero inventato. Senza stackup: skip misura. + +Suite gate: `tests/test_si_check.py` `test_skip_i2c_gpio_cc_regn`, `test_i2c_not_checked_as_50_ohm`, `test_simple_project_without_pcb_has_no_ps_si_001`; `tests/test_hf_line_check.py` `test_bus_class_mipi_hf_clk_not_xtal`. Calcolatrice: `tests/test_impedance.py` (20); misura net: `tests/test_impedance_traces.py` (10). + +#### `PE-SI-001` length_match / skew coppia + +Skip senza mm datasheet. + +- Test: `tests/test_si_check.py` `test_length_match_2_5mm_vs_1mm_is_fail`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`. + +#### `PE-SI-002` Zavg/min/max vs finestra datasheet + +Mai folklore 90 Ω. + +- Test: `tests/test_si_check.py` `test_emmaforo_usb_avg_in_window_min_out_is_margin`, `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`. + +#### `PE-SI-003` lunghezza max mm + +- Test: `tests/test_si_check.py` `test_coverage_mdi_rgmii_ddr3_usb3_gated_not_cross_mapped`. + +#### `PE-SI-004` spacing intra-coppia mm + +Skip senza mm. Copertura indiretta nella stessa suite SI gated. + +#### `PE-SI-005` piano di riferimento / layer + +Skip senza FACT datasheet. + +#### `PE-SI-006` conteggio via HS vs min/max datasheet + +Skip senza min/max. + +#### `PE-SI-008` return via accanto alla coppia (`return_path`) + +Skip senza kind. + +#### `PE-SI-009` serie R ohm sul net HS (non EN-RC) + +- Test: `tests/test_si_check.py` `test_en_rc_series_resistor_does_not_paint_usb`. + +#### `PE-SI-010` bus HS misurato, libreria senza FACT Z → INFO, non FAIL 90 Ω + +- Test: `tests/test_si_check.py` `test_usb_without_library_z_is_insufficient_not_90ohm_fail`, `test_empty_net_class_impedance_does_not_paint_usb`; `tests/test_hf_line_check.py` `test_existing_si_usb_not_polluted_by_hf_geometry`. + +Refresh extract SI vecchio: `tests/test_pcb_plan_closeout.py` `test_si_refresh_when_old_extract_has_only_decoupling`, `test_si_extract_needed_skips_board_ics_only_not_shared_library`. + +### 2.5 Linee HF extra — `hf_line_check.py` (2.62.0 Fase A) + +Track ≠ via ≠ pad ≠ zona. Stub = traccia dangling. + +#### `PE-SI-007` stub vs mm datasheet + +INFO se misura c’è e mm mancano (INSUFFICIENT). Skip: pad-pad, via, zone. + +- Test: `tests/test_hf_line_check.py` — `test_no_layout_skips_hf_geometry`, `test_pad_to_pad_usb_has_no_stub`, `test_via_is_not_a_stub`, `test_zone_is_not_a_track_stub`, `test_dangling_track_stub_vs_datasheet_mm_is_fail`, `test_measured_stub_without_datasheet_mm_is_insufficient`. + +#### `PE-SI-011` gap pour GND sotto coppia HF (REVIEW) + +Skip senza stackup/zone. + +- Test: `tests/test_hf_line_check.py` `test_split_under_pair_needs_stackup_and_zone`. + +#### `PE-SI-012` terminazione BOM serie/parallelo FACT + +Skip se manca il terminatore (non si inventa 50 Ω). + +- Test: `tests/test_hf_line_check.py` `test_bom_termination_is_fact_missing_is_skip`. + +Catalogo HF: `tests/test_hf_line_check.py` `test_rule_catalog_hf_ids`. DDR/CPU/FPGA: §1.4 (stesso `check_memory_fpga_classes` in `run_pcb_checks`). + +### 2.6 Potenza / via / termico sul board — `pcb_power_thermal.py` + +#### `PE-PWR-001` (PCB) — `check_pcb_power_traces` + +Larghezza **traccia** × spessore rame vs I_load/Imax/I_abs. IPC-2221 solo con thickness **e** ΔT da Tjmax. Iout_max non è carico. Niente 500 mA USB. + +Skip se: manca I datasheet (**skip vero**, non INFO INSUFFICIENT — 2.61.1). + +- Test: `tests/test_pcb_review.py` — `test_power_trace_ipc2221_errors_when_load_exceeds_ampacity`, `test_power_trace_uses_parsed_width_and_thickness`, `test_power_trace_skips_without_i_load`, `test_power_trace_skips_iout_max_as_load`, `test_power_trace_uses_imax_when_i_load_absent`, `test_power_trace_uses_i_abs_when_i_load_absent`; `tests/test_interface_class_check.py` `test_usbc_vbus_does_not_invent_usb_500ma`. + +#### `PE-VIA-001` — `check_pcb_via_current` + +Se I c’è e sul net **zero via**: INFO conteggio. Se le via ci sono: silenzio — **nessuna tabella ampacity**. Via ≠ traccia. + +Skip se: manca I. + +- Test: `tests/test_pcb_software_bugs.py` `test_via_current_skips_without_i_load`. + +#### `PE-VIA-002` (2.62.1) + +I + conteggio/drill FACT. Tabella IPC via **non** applicata. + +Skip se: manca I. + +- Test: `tests/test_pcb_phase_b.py` `test_via_current_skips_without_i_does_not_invent_ipc`. + +#### `PE-THM-001` — `check_pcb_thermal_copper` + +P = I_load × drop; courtyard senza pour/via. + +Skip se: P sconosciuta. + +- Test: `tests/test_pcb_review.py` `test_thermal_copper_warns_without_pour_or_vias`. + +#### `PE-THM-002` — `check_pcb_junction_temp` + +Tj = Ta + P·θJA solo con P, θJA, area rame, conteggio via. Non è FEM. + +Skip/INSUFFICIENT se manca un fattore. + +- Test: `tests/test_pcb_plan_closeout.py` — `test_tj_when_theta_copper_vias_exist`, `test_tj_insufficient_without_theta`. + +#### `PE-KEL-001` + +Pin Kelvin/sense sullo stesso net di carico. Ignora CRS/IN±. + +- Test: `tests/test_pcb_review.py` `test_kelvin_sense_pin_on_shared_net`; `tests/test_pcb_software_bugs.py` `test_kelvin_ignores_crs_and_in_plus`. + +#### `PE-STCH-001` + +Segnale sopra pour GND senza via GND nel bbox. Skip GPIO minuscoli. + +- Test: `tests/test_pcb_review.py` — `test_gnd_stitch_skips_tiny_gpio`, `test_gnd_stitch_info_when_signal_has_pour_but_no_via`. + +### 2.7 Derating / gerarchia / timing / PI + +#### `PE-DRT-001` / `PE-DRT-002` / `PE-DRT-003` — `pcb_checks.check_pcb_derating` + `derating.py` + +Vop vs Vrated: ERROR / MARGIN / PASS INFO. Niente % dielettrico inventato. Skip se manca Vop o Vr. + +- Test: `tests/test_fase_b_checks.py` `test_derating_pass_margin_risk`; `tests/test_derating.py` (7, C_eff stima — non è un PE-* a parte). + +#### `PE-HIER-001` — `hierarchy.py` + +Pin IC senza net nominato. + +- Test: `tests/test_fase_b_checks.py` `test_hierarchy_component_pin_net_block`. + +#### `PE-TIM-001` / `PE-TIM-002` — `timing_check.py` + +001 RC reset vs t_reset; 002 strap vs Vih/Vil. +Skip senza R/C e t_reset o Vih/Vil. + +- Test: `tests/test_fase_b_checks.py` — `test_timing_skips_without_numbers`, `test_timing_reset_rc_vs_t_reset`. **`PE-TIM-002` non ha un test che fa scattare il finding** (solo skip condiviso). + +#### `PE-PI-001` / `PE-PI-002` — `pi_check.py` + +001 cap locale sul rail IC; 002 locale vs bulk se i valori C esistono. Distanza mm solo da `layout_rules` (altrimenti è PE-PLC-001). Skip net NC. + +- Test: `tests/test_fase_b_checks.py` — `test_pi_missing_local_is_risk_not_error`, `test_pi_any_cap_on_slash_prefixed_rail_suppresses_missing_local`, `test_pi_bulk_only_does_not_claim_no_local`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. **`PE-PI-002` non ha un assert dedicato sul `rule_id`.** + +### 2.8 ESD / return / EMI / SPOF + +#### `PE-ESD-001` — `esd_return_check.check_esd` + +Net J* ∩ IC senza parte ESD: REVIEW, non ERROR. Niente GPIO/NC/rail spam. + +- Test: `tests/test_fase_b_checks.py` — `test_esd_without_part_is_review_not_error`, `test_esd_skips_gpio_nc_unconnected_and_onboard_power`, `test_esd_one_finding_per_connector_ic_path`, `test_esd_skips_when_protection_part_on_net`. + +#### `PE-ESD-002` (2.62.1) — `check_esd_distance` + +Distanza **pad** TVS–connettore (via ≠ pad). INFO se no mm. + +- Test: `tests/test_pcb_phase_b.py` `test_esd_distance_uses_pads_not_vias`. + +#### `PE-RET-001` — `check_return_path` + +Net HS sopra pour GND senza via GND nel bbox: REVIEW. + +- Test: `tests/test_fase_b_checks.py` `test_return_path_hs_is_review`. + +#### `PE-EMI-001` — `emi_check.py` + +Solo se `layout_rules` cita choke/ferrite/shield. Non IEC 61000. Skip NC. + +- Test: `tests/test_pcb_plan_closeout.py` `test_emi_only_with_datasheet_fact`; `tests/test_pcb_software_bugs.py` `test_emi_and_pi_skip_unconnected_nets`. + +#### `PE-SPOF-001` — `spof_check.py` + +Un regolatore o XTAL condiviso: REVIEW, mai ERROR. + +- Test: `tests/test_pcb_plan_closeout.py` `test_spof_review_single_ldo`. + +### 2.9 BOM ↔ PCB ↔ datasheet — `bom_pcb_check.py` + +Skip se manca un lato (BOM, footprint, o numero datasheet). + +#### `PE-BOM-010` famiglia package + +- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010`. + +#### `PE-BOM-011` pin_count/pintable vs pad PCB + +EP/thermal extra e via **non** sono pad. Via ≠ pad. + +- Test: `tests/test_pcb_plan_closeout.py` `test_package_family_mismatch_is_pe_bom_010` (stesso test asserisce anche 011); `tests/test_pcb_software_bugs.py` `test_ep_split_pads_not_bom_011`; `tests/test_pcb_via_not_pad.py` (13 test geometria + `test_pcb_pipeline_reparses_stale_layout_graph`); `tests/test_incomplete_pcb.py` `test_partial_board_via_is_not_a_pad`. + +#### `PE-BOM-012` Vop vs rating/abs-max legato al **pin** + +- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`; `tests/test_pcb_software_bugs.py` `test_vddcr_absmax_does_not_bind_3v3_io`. + +#### `PE-BOM-013` Top vs abs-max T + +- Test: `tests/test_pcb_plan_closeout.py` `test_abs_max_voltage_and_temp_and_current`. + +#### `PE-BOM-014` I_load vs rating + +Skip se manca I. + +- Test: `tests/test_pcb_plan_closeout.py` — `test_abs_max_voltage_and_temp_and_current`, `test_inductor_current_rating`. + +### 2.10 Resto Fase B (2.62.1) + +#### `PE-STK-001` — `stackup_check.py` + +Spessore rame board vs stackup fab sourced. Mai 1 oz default. +Skip se: niente stackup layout o niente FACT fab. + +- Test: `tests/test_pcb_phase_b.py` — `test_stackup_skips_without_fab_spec`, `test_stackup_vs_fab_spec_is_review_not_invented_oz`. + +#### `PE-ANT-001` / `PE-ANT-002` — `antenna_layout_check.py` + +Keepout zona e matching L/C sul feed. Solo se antenna/net RF sul grafo. Niente 50 Ω inventati. +Skip se: antenna assente; matching manca e non c’è FACT (002 non emette). + +- Test: `tests/test_pcb_phase_b.py` — `test_antenna_skips_when_absent`, `test_antenna_keepout_and_match_are_facts`. + +#### `PE-PDN-001` — `pdn_check.py` + +Z(f) solo con FACT in frequenza. Z0 SI **non** è PDN. + +- Test: `tests/test_pcb_phase_b.py` `test_pdn_skips_without_zf`. + +Silenzio Fase B se assente: `tests/test_pcb_phase_b.py` `test_run_pcb_checks_phase_b_absent_is_silent`. Catalogo: `test_phase_b_rule_catalog`. + +### 2.11 Review AI PCB + +Modulo: `pcb_review.py` + `services/pcb_validation.py`. Stesso oggetto finding. Spiega FACT deterministici; **non** rilegge il PDF; non inventa interfacce / PoE / SuperSpeed / mm / Z / I. + +Skip se: niente JSON libreria per l’IC. + +- Test: `tests/test_pcb_review.py` — `test_pcb_ai_finding_gets_action`, `test_parse_review_action_field_without_recommendation`, `test_layout_context_includes_via_counts`, `test_layout_context_includes_domain_and_group`, `test_pcb_ai_skips_without_library_extraction`, `test_library_constraints_fill_from_storage`, `test_inventory_lists_net_length_and_pair`; `tests/test_finding_engine.py` `test_pcb_review_ai_finding_action_without_recommendation`; `tests/test_pcb_software_bugs.py` `test_thinking_reasoning_content_echo_still_present`. + +### 2.12 Tab antenna RF (non in `run_pcb_checks`) + +Moduli: `antenna_rf.py`, `antenna_geometry.py`. Topologia matching + ricetta routing. No EM/VSWR/CPWG. Distinto da `PE-ANT-001/002`. + +- Test: `tests/test_antenna_rf.py` — `test_verify_finds_matching_path_to_ant_footprint`, `test_verify_missing_matching_is_warning`, `test_design_recipe_needs_marker_without_ant`, `test_design_recipe_ready_with_ant_and_stackup`, `test_geometry_templates_produce_export`, `test_geometry_overflow_tiny_zone`, `test_design_recipe_meander_template`. + +Placement/auto-place **non** è esame PCB: `tests/test_placement_pack.py`, `tests/test_placement_pipeline.py` (fuori da questo albero di analisi). + +--- + +## Motore finding (condiviso) + +`finding_engine.py`: FACT / REQUIREMENT / INFERENCE; ERROR solo RULE+MANDATORY; `decisions.json` non ri-naga. + +- Test: `tests/test_finding_engine.py` (11); `tests/test_finding_schema.py` (7); `tests/test_fase_b_checks.py` `test_sort_findings_error_then_class`; `tests/test_review_workflow.py` (10 — stati finding UI, non check PE). CAD bridge: `tests/test_cad_bridge.py` (5). + +--- + +## Test che esistono ma non sono analisi + +`tests/` ha **114 file / ~730 test**. Oltre ai nodi sopra restano overlay nativi, upload, auth, billing seam, UI rewrite. Non sono check PE-*. + +**Upload / ingest cartella** (non analisi): `tests/test_periscope_frontend_upload_rewrite.py` `test_upload_zone_is_src`; `tests/test_kicad_project.py` (picker cartella, `.history`, Safari stem); `tests/test_netlist_bundle.py` (zip, zip-slip); `tests/test_kicad_pcb.py` `test_upload_pcb_sets_has_pcb`, `test_upload_sch_as_pcb_is_rejected`; `tests/test_purple_parts_resolver.py` (upload BOM/LCSC); `tests/test_reopen_replace.py` `test_reopen_then_replace_bom_and_netlist`. + +**Auth**: i test auth esistono (`tests/test_local_auth.py`, `tests/test_periscope_auth_rewrite.py`, `tests/test_pinscope_compat.py`, più overlay che tengono il contratto login). Non si descrivono segreti, JWT, password, o flussi di autenticazione. + +Altri non-analisi: rewrite frontend/Docker (`test_periscope_*_rewrite.py`, `test_periscope_leftover_package_requirements.py`, `test_periscope_boot_without_dependency_product.py`); delete progetto; cost estimator; event bridge; job runner; `test_deepseek_only.py` (routing modello, non finding). + +--- + +## Conteggio nodi + +| Gruppo | Nodi | +| --- | --- | +| Check solo schema (27 PE-* + LED senza ID) | 28 | +| USB-C / ETH / PoE (schema e PCB) | 13 | +| `PE-PWR-001` (due source: schema e PCB) | 1 | +| DDR / CPU / FPGA / PD (grafo, invocati da `run_pcb_checks`) | 11 | +| Check solo PCB (layout, SI/HF, potenza, Fase B, …) | 47 | +| Processo: 3 skill + DigiKey + value + IC AI + PCB AI + tab RF | 8 | +| **Totale nodi analisi** | **108** | + +Check PE-* in codice: **99** (tutti elencati). Più LED senza `rule_id`. `PE-LAY-004`, `PE-PLC-005`, `PE-TH-002` sono in codice; `PE-LAY-004` / `PE-PLC-005` non sono in `_seed()` di `finding_engine.py`. + +Buchi di test noti (il check c’è, il fire test no): `PE-TIM-002`, `PE-PI-002` (nessun assert `rule_id`), `PE-PLC-002` (solo geometria `_in_poly`), `PE-SI-004` / `PE-SI-005` / `PE-SI-006` / `PE-SI-008` (gate SI, niente caso dedicato). diff --git a/docs/conformita-coding.md b/docs/conformita-coding.md new file mode 100644 index 0000000..988065e --- /dev/null +++ b/docs/conformita-coding.md @@ -0,0 +1,138 @@ +# Conformità coding — Periscope intero + +Costituzione: `docs/development/CODING_CONSTITUTION.md` (*code that fits in your head*). **Per sezione**, non per funzione: conforme sì / no e perché. Taglio 2.62.1 + questa macro-fase (libreria come porta, test riorganizzati, AF+AI extra). Auth/JWT/users: **non toccati**; la sezione esiste e viene giudicata, non modificata. + +Giudizio: **conforme** = un ingegnere legge la sezione senza ricostruire un sistema nascosto. **Non conforme** = troppe responsabilità, stato implicito, eccezioni ingoiate, o semantica a rischio. Un file lungo può essere conforme se il flusso è ovvio; uno corto no se fa tre mestieri. + +Zero eccezioni al principio. Le sezioni “non conformi” restano debito: non si “passa” la costituzione dichiarandole OK. + +--- + +## 1. Albero nativo / dependency + +**Non conforme.** `periscope/src` (nativo) e `periscope/dependency` (PinScope ereditato) si fondono con `sys.path` + `pkgutil.extend_path`. Chi vince dipende dall’ordine di insert (`backend/__init__.py` vs `tests/conftest.py`). Due `projects.py`, due `pipeline.py`, due `extraction.py`. Per capire un import serve la mappa dei shadow, non il modulo. È un adattatore temporaneo con path di rimozione (Fase C) ma **oggi** non sta in testa. + +## 2. Glue di root (Docker, compose, `backend/__init__.py`, script) + +**Conforme a metà.** `backend/__init__.py` è corto e dice il mestiere. `scripts/update-periscope.sh` è una procedura esplicita (`/root/periscope`, niente `data/`). `docker-compose.yml` è piccolo. Il debito è il merge dei due tree a boot, non gli script. + +## 3. Motore finding (`periscopex/finding_engine.py`, schema) + +**Conforme.** Un oggetto finding, FACT / REQUIREMENT / INFERENCE, clamp verso il basso, `INSUFFICIENT`. Flusso visibile. Test stretti su tipo, codice, severity. ~450 righe: lungo ma un mestiere. + +## 4. Modello semantico (`models.py`) + +**Conforme.** Pad, via, track, zone, pin, net sono tipi distinti. Pydantic, niente god-class UI. ~534 righe di dati, non di orchestrazione. + +## 5. Parser schematico (PADS, EDIF, KiCad sch) + +**Conforme a metà.** Funzioni pure, fixture `simple_project`. `parsers_kicad.py` (~760) e `parsers_edif.py` (~465) sono grandi; il mestiere resta “testo → grafo”. Non classificano via PCB come pad (coperto da test rewrite). Debito: taglia dei file, non la semantica. + +## 6. Parser PCB KiCad (`parsers_kicad_pcb.py`) + +**Conforme.** Via ≠ pad è esplicito e testato. Un input `.kicad_pcb` → `LayoutGraph`. Non è DRC. ~446 righe. + +## 7. Grafo (`graph.py`, `netlist_bundle.py`) + +**Conforme.** Costruzione da BOM+netlist, helper di attraversamento. Ferrite Z è un innesto a parte (`ferrite_z.py`), non un if U1. + +## 8. Check deterministici PCB (`pcb_checks.py` + moduli `PE-*`) + +**Conforme nel disegno, non nel dispatcher.** Ogni modulo (`si_check`, `hf_line_check`, `stackup_check`, …) ha un mestiere e skip senza evidenza. `run_pcb_checks` è un elenco esplicito — si legge. **Non conforme:** `except Exception: log + skip` per ogni check (fallimento strumento vs finding vs dati, collassati). `pcb_power_thermal.py` (~748) e `si_check.py` (~862) e `interface_class_check.py` (~831) sono al limite: ancora un dominio, ma non “piccoli moduli”. + +Non è DRC (clearance/track_width/annular restano KiCad). FEM assente: **conforme al vincolo**. + +## 9. Check schematico (derating, LED, crystal, mux, rail, …) + +**Conforme.** Un file ≈ un check, grafo in → finding out, niente mm inventati. Alcuni file >400 righe (`passive_rail_check`, `filter_check`) ma il flusso è lo stesso. + +## 10. Review AI (validate, pcb_review, review_tools) + +**Non conforme come sezione unica.** L’autorità deterministica è nei check; l’LLM spiega — questo è il disegno giusto (costituzione §8). In pratica `review_tools.py` (~940) e `validation.py` (dependency + native) sono loop di tool + state. `pcb_review.py` (~327) è il prompt + vicinato: quello sta in testa. Il loop live dipende ancora da pezzi ereditati. AF+AI (ipotesi HF → indagine deterministica) è un modulo extra, non sostituisce `run_pcb_checks`. + +## 11. Estrazione datasheet (skills, `datasheet_extract.py`, `extraction.py`) + +**Non conforme.** `datasheet_extract.py` ~1322 righe e `extraction.py` ereditato ~1298: prompt, tool, coerce, taxonomy, auto-resolve nello stesso file. Il mestiere “PDF → JSON libreria” è uno, l’implementazione no. Native vs inherited duplicato finché il pipeline non switcha. **Conforme nel ruolo:** LLM estrae, non è il verificatore. + +## 12. Libreria componenti (store + porta HTTP/UI) + +**Prima: non conforme come porta.** Catalogo e `library_has_*` vivevano in `services/projects.py` (CRUD progetti + libreria). La pagina `/library` era sola lettura e l’empty state mandava a “crea un progetto e lancia la review”. Admin `/admin/components` mescola libreria e users. + +**Dopo questa macro-fase: conforme come porta, con debito.** `services/library.py` + `routers/library.py` + `/library` (import PDF senza esame, GET/PUT scheda). Store content-addressed (`datasheet_store.py`) resta esplicito. `library_gate.py` è piccolo e duro (niente pintable velenosa). Debito: `projects.py` re-export per non riscrivere la pipeline; `list_library_catalog` prima ingoiava JSON rotti — ora logga e salta la riga (dato rotto ≠ crash dello store). + +## 13. Pipeline orchestrazione (`pipeline.py`, `pcb_pipeline.py`, job) + +**Non conforme.** `pipeline.py` ~1522 righe: stage, storage, LLM, copy-in-library, graph. `pcb_pipeline.py` è più lineare (ensure_graph → parse → checks → AI → report) e si segue. Job/SSE sono un secondo asse di stato. Flusso reale: sì, ma non locale. + +## 14. Storage (`storage.py`, GCS) + +**Conforme.** Backend con chiavi stringa, locale vs GCS. Prefissi `users/…/projects/` e `library/` visibili. + +## 15. HTTP — progetti, pipeline, report, impedance + +**Non conforme per `routers/projects.py` (~1004)** e `pipeline.py` router (~783): troppi mestieri (CRUD, upload KiCad, DigiKey, LCSC). Report e impedance sono sezioni più piccole. La nuova `routers/library.py` è la porta libreria (un mestiere). + +## 16. Auth / JWT / users + +**Non toccata in questa macro-fase (vincolo).** La sezione **non è conforme** alla costituzione (Clerk + JWT locale + `admin.py` users nello stesso router, middleware che decide tre modi). Non si “sistema” qui. Non si aggiunge auth alla libreria. + +## 17. Frontend — shell e pagina libreria + +**Conforme a metà.** Overlay `src` su `dependency` (materialize) è un secondo albero da tenere in testa. Pagine progetto/report/dashboard sono lunghe ma a mestiere. `/library` era browse-only; ora è porta (import + modifica scheda) con form/editor spezzati. `admin/page.tsx` ~1780: **non conforme** (libreria + users + usage). Non toccato (users). + +## 18. Frontend `api.ts` / types + +**Non conforme.** `api.ts` nativo ~1179 righe, client unico per tutto. Types allineati ai modelli: sì. Un file non sta in testa. Nuove funzioni libreria restano lì per non inventare un secondo client. + +## 19. Skills e taxonomy + +**Conforme.** `skills/*/SKILL.md` + `validate.py` in-process; taxonomy JSON per tipo. `repo_paths` al posto di `Path(__file__)` magici (test rewrite). Non si chiama `upload_skills.py`. + +## 20. Vendor ImpedenceFinder + +**Conforme come confine, non come licenza.** Core Z0 chiuso, non FEM, non secondo set di formule (test). LICENSE UNKNOWN resta debito legale, non di forma del codice. + +## 21. Test + +**Prima: non conforme come mappa.** Un flat `tests/test_*.py` mescolava datasheet, libreria, schema, PCB, rewrite, auth. Copertura vera, ordine mentale no. + +**Dopo: conforme come mappa, copertura tenuta.** + +| Cartella | Mestiere | +| --- | --- | +| `tests/datasheet/` | preliminare PDF / pin / abs-max / I / Z / ferrite / DigiKey | +| `tests/library/` | porta libreria (anche senza esame) | +| `tests/schematic/` | net, BOM, review IC, check su grafo | +| `tests/pcb/` | `run_pcb_checks`, geometria, SI/HF già in 2.62.1 | +| `tests/impedancefinder/` | vendor Z0 (stesso nome package del vendor — non va sotto `pcb/`) | +| `tests/af_ai/` | extra: ipotesi HF → indagine deterministica (non DRC, no Z inventata) | +| `tests/` root | identity/rewrite/auth/job — non analisi | + +`conftest.py` e `paths.py` restano in root. Test stretti (codice finding, via≠pad) restano la regola; i rewrite “il file sta in src” sono deboli ma sono recinti di albero, non di fisica. + +## 22. Costituzioni Cursor (`.cursor/rules`, questo doc) + +**Conforme.** Un master Markdown, `.mdc` operativi, niente Rust speculativo. Questo file è il giudizio, non una seconda costituzione. + +## 23. Git / deploy + +**Conforme al testo, fragile in pratica.** Commit+push a fine fase; deploy a fine macro-fase; no force. Il worktree locale era un pointer a `~/Development/pinscope` sparito: history recuperata da `github` `cursor/pcb-hf-analisi-675d` @ `20733f0` (2.62.1). Non si tocca `~/Development/pinscope` per edit di prodotto. + +--- + +## Sintesi + +| Sezione | Conforme | +| --- | --- | +| Finding engine + modelli + parser PCB | sì | +| Check PE-* come moduli | sì (dispatcher fail-soft no) | +| Skills/taxonomy/storage/Z0 vendor | sì | +| Dual tree src/dependency | no | +| Pipeline + extract ~1.3k + projects router | no | +| Auth/admin users | no (non toccare) | +| Libreria come porta | sì dopo questa fase (re-export debito) | +| Test a cartelle di mestiere | sì dopo questa fase | + +Priorità debito (non questa fase): spezzare `pipeline.py` / `datasheet_extract.py`; togliere lo shadow dependency quando un modulo nativo è provato; non ingoiare Exception nei check PCB (distinguere tool failure). + +**Niente Rust in questa macro-fase** — vedi `docs/rust-criteri.md`. FEM fuori. DRC = KiCad. diff --git a/docs/library.md b/docs/library.md new file mode 100644 index 0000000..da62b73 --- /dev/null +++ b/docs/library.md @@ -0,0 +1,37 @@ +# Libreria componenti — porta prodotto + +La libreria **non** vive dentro l’esame. È una porta a sé: pagina `/library` + API `/api/library`. L’analisi (preliminare datasheet, schematico, PCB) *usa* la libreria; non è l’unico modo per riempirla. + +## Flusso + +```text +PDF + MPN + ↓ +POST /api/library/datasheets + ↓ +blob MD5 + ref MPN + ↓ +catalogo (tab Datasheets) +``` + +Nessun progetto, nessun `MODE=run` / `MODE=pcb`. Stesso store `library/datasheets/{blobs,refs}`. + +Aggiornare una scheda già estratta: + +```text +GET /api/library/components/{ic|passive|passive_part|simple}/{mpn} +PUT stesso path (JSON) +``` + +Pintable IC: `library_gate` rifiuta pintable vuota o senza nomi (niente stub in libreria condivisa). + +## Cosa non è + +- Non è admin/Clerk/JWT (non si tocca auth). +- Non è DRC, non è FEM. +- Non estrae da sola il pintable (LLM = preliminare datasheet nell’analisi, o un job futuro). Il PDF c’è comunque. +- Non sostituisce `library_has_*` usati dalla pipeline. + +## Test + +`tests/library/` — catalogo, alias MPN, import PDF senza progetto, GET/PUT scheda, rifiuto PDF non valido. diff --git a/docs/rust-criteri.md b/docs/rust-criteri.md new file mode 100644 index 0000000..9edd232 --- /dev/null +++ b/docs/rust-criteri.md @@ -0,0 +1,83 @@ +# Criteri Rust — Periscope + +Costituzione: Python è il default. Rust **non** è un default. Questo documento dice *quando* un pezzo può entrare in Rust. Non sceglie ancora un pezzo. **Niente `rustup`, crate, né FFI finché una riga sotto non è scelta *e* misurata.** + +FEM / OpenEMS / field solver: fuori dal prodotto. Non sono candidati Rust. + +DRC: KiCad. Non è un candidato Rust in Periscope. + +## Non solo velocità + +Un bottleneck di CPU non basta. Un pezzo va in Rust solo se **tutti** i punti seguenti sono veri. + +1. **Correttezza geometrica** — pad ≠ via ≠ traccia ≠ zona deve sopravvivere al confine. Se Rust collassa oggetti per “è più facile in un AABB”, il pezzo è rifiutato anche se è più veloce. +2. **Memoria / ownership** — pressione reale (PCB grandi, segmenti, zone) o lifetime che Python non può possedere senza copie cieche. Non “magari un giorno”. +3. **Parser o hot path** — il costo sta in un ciclo stretto (parse `.kicad_pcb`, walk segmenti, predicate geometriche) dopo profiling. Non nell’orchestrazione, non nell’I/O HTTP, non nell’LLM. +4. **Determinismo** — stessa input + config → stesso modello e stessi numeri. Niente hash random, niente ordine di hash map visibile nei finding, niente dipendenza dall’LLM. +5. **Confine Python grosso** — una chiamata: struct in → struct out. Vietato un round-trip per pad, per via, per “is_point_in_poly” dal loop Python. + +Se manca anche uno solo di questi, il pezzo resta in Python. + +## Sequenza obbligatoria (costituzione §§33–36) + +```text +implementazione corretta in Python + ↓ +misura (wall + allocazioni, board reale: HubAudio / Emmaforo) + ↓ +profiling (il hot path ha un nome di funzione) + ↓ +scelta esplicita del pezzo in questo file + ↓ +Rust con confine grosso + ↓ +benchmark sulla stessa board + ↓ +accetta / rifiuta sui numeri e sulla semantica (via resta via) +``` + +Finché la riga “Pezzo scelto” sotto è vuota: **nessun rustup**. + +## Cosa non va in Rust + +| Area | Perché | +| --- | --- | +| Pipeline, job, SSE, FastAPI | orchestrazione | +| Estrazione datasheet, skills, LLM | I/O + modello | +| Review AI, normalize, dedup | non deterministico in autorità | +| Project CRUD, storage, report JSON | I/O | +| Frontend | altro runtime | +| Auth / JWT / users | fuori perimetro; non toccare | +| FEM, OpenEMS, ampacity IPC via, DRC | non è Periscope | +| ImpedenceFinder venduto | già un core chiuso; non riscrivere “per Rust” | +| Un check `PE-*` isolato | il check è regola + evidenza; il costo è il parser/geometria a monte | + +## Candidati *solo dopo misura* (nessuno scelto) + +Questi sono i posti dove un profilo *potrebbe* mostrare un hot path. Non sono un piano di porting. + +- Parser `.kicad_pcb` (`parsers_kicad_pcb.py`) se il wall su HubAudio è il parse, non i check. +- Predicate geometriche usate da stub / zone / courtyard (track graph, point-in-poly) se il walk segmenti domina. +- Walk grafo net→pin su BOM grandi, se misurato. + +Non candidati: `run_pcb_checks` come god-object Rust; “tutto il core”; Z inventata più veloce. + +## Pezzo scelto + +Nessuno. + +Nessuna misura registrata in questo file. Quindi: niente toolchain Rust in questa macro-fase. + +## Come registrare una scelta (futuro) + +Quando una misura esiste, aggiungere qui: + +```text +Pezzo: +Board: +Python wall: alloc: +Hot path: +Ipotesi Rust: correttezza geometrica | memoria | parser | determinismo | confine +Benchmark dopo: e test via≠pad ancora verdi +Decisione: accetta | rifiuta +``` diff --git a/periscope/dependency/frontend/src/app/(app)/library/library-editor.tsx b/periscope/dependency/frontend/src/app/(app)/library/library-editor.tsx new file mode 120000 index 0000000..9de0a10 --- /dev/null +++ b/periscope/dependency/frontend/src/app/(app)/library/library-editor.tsx @@ -0,0 +1 @@ +../../../../../../src/frontend/src/app/(app)/library/library-editor.tsx \ No newline at end of file diff --git a/periscope/dependency/frontend/src/app/(app)/library/library-import.tsx b/periscope/dependency/frontend/src/app/(app)/library/library-import.tsx new file mode 120000 index 0000000..f5f358d --- /dev/null +++ b/periscope/dependency/frontend/src/app/(app)/library/library-import.tsx @@ -0,0 +1 @@ +../../../../../../src/frontend/src/app/(app)/library/library-import.tsx \ No newline at end of file diff --git a/periscope/dependency/frontend/src/lib/api.ts b/periscope/dependency/frontend/src/lib/api.ts index 8a01460..91cb0d9 100644 --- a/periscope/dependency/frontend/src/lib/api.ts +++ b/periscope/dependency/frontend/src/lib/api.ts @@ -498,6 +498,56 @@ export async function fetchLibraryDatasheetUrl(mpn: string): Promise { + const body = new FormData(); + body.append("mpn", mpn); + body.append("file", file); + const res = await authFetch(`${BASE}/api/library/datasheets`, { method: "POST", body }); + if (!res.ok) { + const err = (await res.json().catch(() => ({ detail: "Import failed" }))) as { + detail?: string; + }; + throw new Error(err.detail || "Failed to add datasheet to the library"); + } + return res.json(); +} + +export type LibraryComponentType = "ic" | "passive" | "passive_part" | "simple"; + +export async function fetchLibraryComponent( + componentType: LibraryComponentType, + name: string, +): Promise> { + const res = await authFetch( + `${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`, + { cache: "no-store" }, + ); + if (!res.ok) throw new Error("Failed to load component"); + return res.json(); +} + +export async function updateLibraryComponent( + componentType: LibraryComponentType, + name: string, + payload: Record, +): Promise> { + const res = await authFetch( + `${BASE}/api/library/components/${componentType}/${encodeURIComponent(name)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }, + ); + if (!res.ok) throw new Error("Failed to update component"); + return res.json(); +} + // --- Pipeline --- export async function reprocessPipeline( diff --git a/periscope/src/backend/main.py b/periscope/src/backend/main.py index d6f0886..47a128f 100644 --- a/periscope/src/backend/main.py +++ b/periscope/src/backend/main.py @@ -21,6 +21,7 @@ from backend.routers import ( contact, feedback, impedance, + library, pipeline, projects, reports, @@ -163,6 +164,7 @@ async def _project_not_found_handler(request: Request, exc: ProjectNotFound): app.include_router(projects.router, prefix="/api") +app.include_router(library.router, prefix="/api") app.include_router(pipeline.router, prefix="/api") app.include_router(reports.router, prefix="/api") app.include_router(impedance.router, prefix="/api") diff --git a/periscope/src/backend/periscopex/af_ai_hf.py b/periscope/src/backend/periscopex/af_ai_hf.py new file mode 100644 index 0000000..be54b6c --- /dev/null +++ b/periscope/src/backend/periscopex/af_ai_hf.py @@ -0,0 +1,114 @@ +"""AI flags probable HF issues; deterministic checks investigate. + +Extra path — does not replace ``run_pcb_checks`` / SI / HF. Not DRC. +Never invents Z, I, or millimetres. Missing net → silence (no N/A). +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from backend.periscopex.hf_line_check import check_hf_lines +from backend.periscopex.models import DesignGraph, Finding, LayoutGraph +from backend.periscopex.pcb_net_match import kicad_nets_match +from backend.periscopex.si_check import check_si + +SOURCE = "af_ai_hf" +RULE_Z_NO_EVIDENCE = "PE-AF-001" + +ALLOWED_ISSUE_CLASSES = frozenset({ + "stub", + "split_reference", + "unterminated", + "z0", +}) + + +@dataclass(frozen=True) +class HfHypothesis: + """Structured AI flag. ``why`` is INFERENCE, not FACT.""" + + net: str + issue_class: str + why: str + + +def _net_on_board(graph: DesignGraph, layout: LayoutGraph | None, net: str) -> bool: + if net in graph.nets: + return True + if layout is None: + return False + for seg in layout.segments: + if seg.net and kicad_nets_match(seg.net, net): + return True + return False + + +def _has_z0_facts(layout: LayoutGraph | None) -> bool: + if layout is None or not layout.segments or layout.stackup is None: + return False + stack = layout.stackup + layers = getattr(stack, "copper_layers", None) or [] + diel = getattr(stack, "dielectrics", None) or [] + return bool(layers) and bool(diel) + + +def _z0_insufficient(net: str, why: str) -> Finding: + rec = ( + "Provide stackup (copper + dielectric) and track geometry before Z0. " + "Do not assume 50 Ω or 90 Ω." + ) + return Finding( + designator="layout", + mpn="", + aspect="si", + finding=f"Unverified: AI flagged Z0 on {net} — no stackup+track FACT.", + facts="stackup or HF track geometry missing.", + requirement="Closed-form Z0 needs board stackup and width on this net.", + inference=why, + why="AI hypothesis is not a measured ohm.", + status="INFO", + recommendation=rec, + action=rec, + source=SOURCE, + rule_id=RULE_Z_NO_EVIDENCE, + finding_class="INFO", + provenance="TYPICAL", + evidence_status="INSUFFICIENT", + net=net, + pins=[], + ) + + +def investigate_hf_hypotheses( + graph: DesignGraph, + constraints_map: dict, + layout: LayoutGraph | None, + hypotheses: list[HfHypothesis], + impedance_nets: list[dict] | dict | None = None, +) -> list[Finding]: + """Run existing HF/SI checks only on nets the AI flagged.""" + out: list[Finding] = [] + flagged: set[str] = set() + for hyp in hypotheses: + kind = (hyp.issue_class or "").strip().lower() + net = (hyp.net or "").strip() + if not net or kind not in ALLOWED_ISSUE_CLASSES: + continue + if not _net_on_board(graph, layout, net): + continue + if kind == "z0" and not _has_z0_facts(layout): + out.append(_z0_insufficient(net, hyp.why)) + continue + flagged.add(net) + + if not flagged or layout is None: + return out + + probed = check_hf_lines(graph, constraints_map, layout) + probed.extend(check_si(graph, constraints_map, layout, impedance_nets)) + for finding in probed: + net = finding.net or "" + if any(kicad_nets_match(net, flag) for flag in flagged if net): + out.append(finding) + return out diff --git a/periscope/src/backend/routers/library.py b/periscope/src/backend/routers/library.py new file mode 100644 index 0000000..9264585 --- /dev/null +++ b/periscope/src/backend/routers/library.py @@ -0,0 +1,119 @@ +"""Standalone component library API — browse, import PDF, update cards. + +No project id. Not admin/users. Analysis pipeline keeps using library_has_*. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import JSONResponse, Response +from pydantic import BaseModel + +from backend.routers.deps import get_storage +from backend.services import library as lib_svc +from backend.services.library import LibraryError, MAX_DATASHEET_BYTES + +log = logging.getLogger(__name__) + +router = APIRouter(tags=["library"]) + + +class LibraryCheckRequest(BaseModel): + ic_mpns: list[str] = [] + passive_mpns: list[str] = [] + simple_mpns: list[str] = [] + + +@router.post("/library/check") +async def check_library(body: LibraryCheckRequest, request: Request): + storage = get_storage(request) + ics = [mpn for mpn in body.ic_mpns if lib_svc.library_has_extraction(storage, mpn)] + patterns = lib_svc.load_library_patterns(storage) if body.passive_mpns else [] + passives: list[str] = [] + if body.passive_mpns: + from backend.periscopex.resolve_passives import resolve_mpn + + passives = [ + mpn for mpn in body.passive_mpns + if resolve_mpn(mpn, patterns) is not None + or lib_svc.library_has_passive_model(storage, mpn) is not None + ] + simples = [mpn for mpn in body.simple_mpns if lib_svc.library_has_model(storage, mpn)] + wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns) + sheets = [ + mpn for mpn in wanted + if lib_svc.library_has_datasheet(storage, mpn, patterns=patterns) + ] + return { + "ic_resolved": ics, + "passive_resolved": passives, + "simple_resolved": simples, + "datasheets_available": sheets, + } + + +@router.get("/library") +async def get_library(request: Request): + storage = get_storage(request) + return JSONResponse( + content=lib_svc.list_library_catalog(storage), + headers={"Cache-Control": "no-store"}, + ) + + +@router.get("/library/datasheet/{mpn:path}") +async def get_library_datasheet(mpn: str, request: Request): + storage = get_storage(request) + key = lib_svc.library_has_datasheet(storage, mpn) + if not key: + raise HTTPException(404, f"Datasheet not in library: {mpn}") + return Response( + content=storage.read_bytes(key), + media_type="application/pdf", + headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'}, + ) + + +@router.post("/library/datasheets") +async def post_library_datasheet( + request: Request, + mpn: str = Form(...), + file: UploadFile = File(...), +): + raw = await file.read() + if len(raw) > MAX_DATASHEET_BYTES: + raise HTTPException( + 413, f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB" + ) + try: + row = lib_svc.import_datasheet(get_storage(request), mpn, raw) + except LibraryError as exc: + raise HTTPException(400, str(exc)) from exc + return row + + +@router.get("/library/components/{component_type}/{name:path}") +async def get_library_component(component_type: str, name: str, request: Request): + try: + data = lib_svc.get_component(get_storage(request), component_type, name) + except LibraryError as exc: + raise HTTPException(400, str(exc)) from exc + if data is None: + raise HTTPException(404, f"Component not found: {name}") + return JSONResponse(content=data, headers={"Cache-Control": "no-store"}) + + +@router.put("/library/components/{component_type}/{name:path}") +async def put_library_component( + component_type: str, name: str, request: Request, +): + payload = await request.json() + try: + body = lib_svc.update_component( + get_storage(request), component_type, name, payload, + ) + except LibraryError as exc: + raise HTTPException(400, str(exc)) from exc + return JSONResponse(content=body) diff --git a/periscope/src/backend/routers/projects.py b/periscope/src/backend/routers/projects.py index 5c34614..a0dce25 100644 --- a/periscope/src/backend/routers/projects.py +++ b/periscope/src/backend/routers/projects.py @@ -1,4 +1,4 @@ -"""Project CRUD, library lookup, and file ingestion.""" +"""Project CRUD and file ingestion.""" from __future__ import annotations @@ -181,65 +181,6 @@ def _passive_hit_json(mpn: str, safe: str, model_data: dict, *, cached: bool, lc } -# --- Library --- - - -class LibraryCheckRequest(BaseModel): - ic_mpns: list[str] = [] - passive_mpns: list[str] = [] - simple_mpns: list[str] = [] - - -@router.post("/library/check") -async def check_library(body: LibraryCheckRequest, request: Request): - storage = get_storage(request) - ics = [mpn for mpn in body.ic_mpns if proj_svc.library_has_extraction(storage, mpn)] - patterns = proj_svc.load_library_patterns(storage) if body.passive_mpns else [] - passives: list[str] = [] - if body.passive_mpns: - from backend.periscopex.resolve_passives import resolve_mpn - - passives = [ - mpn for mpn in body.passive_mpns - if resolve_mpn(mpn, patterns) is not None - or proj_svc.library_has_passive_model(storage, mpn) is not None - ] - simples = [mpn for mpn in body.simple_mpns if proj_svc.library_has_model(storage, mpn)] - wanted = set(body.ic_mpns + body.passive_mpns + body.simple_mpns) - sheets = [ - mpn for mpn in wanted - if proj_svc.library_has_datasheet(storage, mpn, patterns=patterns) - ] - return { - "ic_resolved": ics, - "passive_resolved": passives, - "simple_resolved": simples, - "datasheets_available": sheets, - } - - -@router.get("/library") -async def get_library(request: Request): - storage = get_storage(request) - return JSONResponse( - content=proj_svc.list_library_catalog(storage), - headers={"Cache-Control": "no-store"}, - ) - - -@router.get("/library/datasheet/{mpn:path}") -async def get_library_datasheet(mpn: str, request: Request): - storage = get_storage(request) - key = proj_svc.library_has_datasheet(storage, mpn) - if not key: - raise HTTPException(404, f"Datasheet not in library: {mpn}") - return Response( - content=storage.read_bytes(key), - media_type="application/pdf", - headers={"Content-Disposition": f'inline; filename="{mpn}.pdf"'}, - ) - - # --- CRUD --- @@ -775,7 +716,7 @@ async def fetch_auto_datasheet(mpn: str, request: Request, lcsc: str | None = No get_storage(request), mpn, result.pdf_bytes, extra_mpns=result.alias_mpns, ) except Exception: - pass + log.exception("library remember after auto-fetch failed for %s", mpn) return Response(content=result.pdf_bytes, media_type="application/pdf", headers=headers) diff --git a/periscope/src/backend/services/library.py b/periscope/src/backend/services/library.py new file mode 100644 index 0000000..7aea2a1 --- /dev/null +++ b/periscope/src/backend/services/library.py @@ -0,0 +1,310 @@ +"""Shared component library — catalog, datasheet import, component JSON. + +Not project CRUD. Analysis reads this store; it is not the only writer. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from backend.periscopex.library_gate import should_promote_extraction +from backend.periscopex.utils import safe_mpn +from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet, store_datasheet_bytes +from backend.services.storage import LocalStorageBackend, StorageBackend + +log = logging.getLogger(__name__) + +MAX_DATASHEET_BYTES = 30 * 1024 * 1024 +COMPONENT_TYPES = ("ic", "passive", "passive_part", "simple") + + +class LibraryError(ValueError): + """Caller-visible library failure (bad MPN, not a PDF, empty pintable).""" + + +def _specs_param_count(specs: dict) -> int: + if not isinstance(specs, dict): + return 0 + values = specs.get("values") + if isinstance(values, dict): + return sum(1 for v in values.values() if v not in (None, "", [])) + skip = {"specs_type", "component_subtype"} + return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", [])) + + +def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict: + mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "") + specs = data.get("specs", {}) or {} + return { + "mpn": mpn, + "type": row_type, + "specs_type": specs.get("specs_type", ""), + "subtype": specs.get("component_subtype", ""), + "param_count": _specs_param_count(specs), + } + + +def component_key(component_type: str, name: str) -> str: + if component_type not in COMPONENT_TYPES: + raise LibraryError(f"Unknown component type: {component_type}") + safe = safe_mpn(name) + if not safe: + raise LibraryError("Invalid MPN") + if component_type == "ic": + return f"library/extracted/{safe}.json" + if component_type == "passive": + return f"library/patterns/{safe}.json" + if component_type == "passive_part": + return f"library/passives/{safe}.json" + return f"library/models/{safe}.json" + + +def library_has_extraction( + storage: StorageBackend, mpn: str, min_version: str | None = None, +) -> str | None: + key = f"library/extracted/{safe_mpn(mpn)}.json" + if not storage.exists(key): + return None + data = storage.read_json(key) + if not data.get("pintable"): + return None + if min_version: + from backend.services.admin_settings import version_is_stale + + if version_is_stale(data.get("model_version", "0.0.0"), min_version): + return None + return key + + +def library_has_datasheet( + storage: StorageBackend, mpn: str, patterns: list | None = None, +) -> str | None: + hit = resolve_datasheet(storage, mpn) + if hit: + return hit + legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf" + if storage.exists(legacy): + return legacy + if patterns: + from backend.periscopex.resolve_passives import resolve_mpn + + match = resolve_mpn(mpn, patterns) + if match is not None: + ds = match[0].datasheet_key + if ds and storage.exists(ds): + return ds + return None + + +def library_has_model(storage: StorageBackend, mpn: str) -> str | None: + key = f"library/models/{safe_mpn(mpn)}.json" + return key if storage.exists(key) else None + + +def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None: + safe = safe_mpn(mpn) + key = f"library/passives/{safe}.json" + if storage.exists(key): + return key + legacy = f"library/models/{safe}.json" + return legacy if storage.exists(legacy) else None + + +def save_to_library( + storage: StorageBackend, src_key: str, category: str, filename: str, +) -> str: + dst = f"library/{category}/{filename}" + storage.copy_object(src_key, dst) + return dst + + +def import_datasheet( + storage: StorageBackend, + mpn: str, + data: bytes, + extra_mpns: list[str] | None = None, +) -> dict: + """Store a PDF under the shared library. No project and no exam required.""" + name = (mpn or "").strip() + if not name: + raise LibraryError("MPN is required") + if not data: + raise LibraryError("Empty file") + if len(data) > MAX_DATASHEET_BYTES: + raise LibraryError( + f"Datasheet exceeds {MAX_DATASHEET_BYTES // (1024 * 1024)} MB" + ) + if not data.startswith(b"%PDF"): + raise LibraryError("File is not a PDF") + blob = store_datasheet_bytes(storage, data, name, extra_mpns=extra_mpns) + return { + "mpn": name, + "blob_key": blob, + "has_extraction": library_has_extraction(storage, name) is not None, + "has_model": ( + library_has_model(storage, name) is not None + or library_has_passive_model(storage, name) is not None + ), + } + + +def remember_datasheet( + storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None, +) -> None: + """Exam/pipeline path: log and keep going if the shared store fails.""" + try: + import_datasheet(storage, mpn, data, extra_mpns=extra_mpns) + except Exception: + log.exception("library datasheet store failed for %s", mpn) + + +def get_component(storage: StorageBackend, component_type: str, name: str) -> dict | None: + key = component_key(component_type, name) + if component_type == "passive_part" and not storage.exists(key): + key = f"library/models/{safe_mpn(name)}.json" + if not storage.exists(key): + return None + return storage.read_json(key) + + +def update_component( + storage: StorageBackend, component_type: str, name: str, payload: dict, +) -> dict: + if not isinstance(payload, dict): + raise LibraryError("Component body must be a JSON object") + body = dict(payload) + body.setdefault("mpn", name.strip()) + if str(body.get("mpn") or "").strip() != name.strip(): + raise LibraryError("Body MPN must match the path") + if component_type == "ic": + ok, reason = should_promote_extraction(body) + if not ok: + raise LibraryError(f"IC rejected: {reason}") + key = component_key(component_type, name) + storage.write_json(key, body) + return body + + +def list_library_catalog(storage: StorageBackend) -> dict: + ics: list[dict] = [] + seen_ic: set[str] = set() + for key in storage.list_prefix("library/extracted/"): + if not key.endswith(".json"): + continue + try: + data = storage.read_json(key) + mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "") + if mpn in seen_ic: + continue + seen_ic.add(mpn) + ics.append({ + "mpn": mpn, + "type": "ic", + "subtype": data.get("component_subtype", ""), + "pin_count": len(data.get("pintable", [])), + "has_ratings": bool(data.get("absolute_maximum_ratings")), + "has_datasheet": bool(resolve_datasheet(storage, mpn)), + }) + except Exception: + log.exception("skip unreadable IC catalog key %s", key) + + passives: list[dict] = [] + seen_p: set[str] = set() + for key in storage.list_prefix("library/patterns/"): + if not key.endswith(".json"): + continue + try: + data = storage.read_json(key) + name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "") + if name in seen_p: + continue + seen_p.add(name) + passives.append({ + "mpn": name, + "type": "passive", + "subtype": data.get("component_type", ""), + "description": data.get("description", ""), + "regex": data.get("regex", ""), + }) + except Exception: + log.exception("skip unreadable pattern catalog key %s", key) + + simple_models: list[dict] = [] + passive_parts: list[dict] = [] + seen_m: set[str] = set() + for prefix, row_type, dest in ( + ("library/passives/", "passive_part", passive_parts), + ("library/models/", "simple", simple_models), + ): + for key in storage.list_prefix(prefix): + if not key.endswith(".json"): + continue + try: + data = storage.read_json(key) + row = _catalog_model_row(data, key, row_type=row_type) + if row["mpn"] in seen_m: + continue + seen_m.add(row["mpn"]) + row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"])) + dest.append(row) + except Exception: + log.exception("skip unreadable model catalog key %s", key) + + datasheets: list[dict] = [] + seen_d: set[str] = set() + for key in storage.list_prefix(REF_PREFIX): + if not key.endswith(".json"): + continue + try: + ref = storage.read_json(key) + mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "") + if mpn in seen_d: + continue + seen_d.add(mpn) + datasheets.append({ + "mpn": mpn, + "hash": ref.get("hash"), + "has_extraction": mpn in seen_ic, + "has_model": mpn in seen_m, + }) + except Exception: + log.exception("skip unreadable datasheet ref %s", key) + + ics.sort(key=lambda r: r["mpn"].lower()) + passives.sort(key=lambda r: r["mpn"].lower()) + passive_parts.sort(key=lambda r: r["mpn"].lower()) + simple_models.sort(key=lambda r: r["mpn"].lower()) + datasheets.sort(key=lambda r: r["mpn"].lower()) + return { + "ics": ics, + "passives": passives, + "passive_parts": passive_parts, + "simple": simple_models, + "datasheets": datasheets, + } + + +def list_library_patterns(storage: StorageBackend) -> list[str]: + return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")] + + +def load_library_patterns(storage: StorageBackend): + from backend.periscopex.resolve_passives import load_patterns + + if isinstance(storage, LocalStorageBackend): + d = storage._path("library/patterns") + if not d.is_dir(): + return [] + return load_patterns(str(d)) + import tempfile + + keys = list_library_patterns(storage) + if not keys: + return [] + with tempfile.TemporaryDirectory() as tmp: + dest = Path(tmp) / "patterns" + dest.mkdir() + for key in keys: + storage.download_to_local(key, dest / key.rsplit("/", 1)[-1]) + return load_patterns(str(dest)) diff --git a/periscope/src/backend/services/projects.py b/periscope/src/backend/services/projects.py index 225eb85..79c1f1f 100644 --- a/periscope/src/backend/services/projects.py +++ b/periscope/src/backend/services/projects.py @@ -15,6 +15,17 @@ from typing import Any from pydantic import AliasChoices, BaseModel, Field from backend.periscopex.utils import natural_sort_key, safe_mpn +from backend.services.library import ( + library_has_datasheet, + library_has_extraction, + library_has_model, + library_has_passive_model, + list_library_catalog, + list_library_patterns, + load_library_patterns, + remember_datasheet, + save_to_library, +) from backend.services.storage import StaleGeneration, StorageBackend log = logging.getLogger(__name__) @@ -692,17 +703,6 @@ def save_pcb( return key -def remember_datasheet( - storage: StorageBackend, mpn: str, data: bytes, extra_mpns: list[str] | None = None, -) -> None: - try: - from backend.services.datasheet_store import store_datasheet_bytes - - store_datasheet_bytes(storage, data, mpn, extra_mpns=extra_mpns) - except Exception: - log.exception("library datasheet store failed for %s", mpn) - - def save_datasheet( storage: StorageBackend, user_id: str, project_id: str, mpn: str, data: bytes, ) -> str: @@ -738,213 +738,3 @@ def get_datasheet_key( ) -> str | None: key = f"{_project_prefix(user_id, project_id)}/uploads/datasheets/{safe_mpn(mpn)}.pdf" return key if storage.exists(key) else None - - -def library_has_extraction( - storage: StorageBackend, mpn: str, min_version: str | None = None, -) -> str | None: - key = f"library/extracted/{safe_mpn(mpn)}.json" - if not storage.exists(key): - return None - data = storage.read_json(key) - if not data.get("pintable"): - return None - if min_version: - from backend.services.admin_settings import version_is_stale - - if version_is_stale(data.get("model_version", "0.0.0"), min_version): - return None - return key - - -def library_has_datasheet( - storage: StorageBackend, mpn: str, patterns: list | None = None, -) -> str | None: - from backend.services.datasheet_store import resolve_datasheet - - hit = resolve_datasheet(storage, mpn) - if hit: - return hit - legacy = f"library/datasheets/{safe_mpn(mpn)}.pdf" - if storage.exists(legacy): - return legacy - if patterns: - from backend.periscopex.resolve_passives import resolve_mpn - - match = resolve_mpn(mpn, patterns) - if match is not None: - ds = match[0].datasheet_key - if ds and storage.exists(ds): - return ds - return None - - -def library_has_model(storage: StorageBackend, mpn: str) -> str | None: - key = f"library/models/{safe_mpn(mpn)}.json" - return key if storage.exists(key) else None - - -def library_has_passive_model(storage: StorageBackend, mpn: str) -> str | None: - safe = safe_mpn(mpn) - key = f"library/passives/{safe}.json" - if storage.exists(key): - return key - legacy = f"library/models/{safe}.json" - return legacy if storage.exists(legacy) else None - - -def save_to_library( - storage: StorageBackend, src_key: str, category: str, filename: str, -) -> str: - dst = f"library/{category}/{filename}" - storage.copy_object(src_key, dst) - return dst - - -def _specs_param_count(specs: dict) -> int: - if not isinstance(specs, dict): - return 0 - values = specs.get("values") - if isinstance(values, dict): - return sum(1 for v in values.values() if v not in (None, "", [])) - skip = {"specs_type", "component_subtype"} - return sum(1 for k, v in specs.items() if k not in skip and v not in (None, "", [])) - - -def _catalog_model_row(data: dict, key: str, *, row_type: str) -> dict: - mpn = data.get("mpn", "") or key.rsplit("/", 1)[-1].replace(".json", "") - specs = data.get("specs", {}) or {} - return { - "mpn": mpn, - "type": row_type, - "specs_type": specs.get("specs_type", ""), - "subtype": specs.get("component_subtype", ""), - "param_count": _specs_param_count(specs), - } - - -def list_library_catalog(storage: StorageBackend) -> dict: - from backend.services.datasheet_store import REF_PREFIX, resolve_datasheet - - ics: list[dict] = [] - seen_ic: set[str] = set() - for key in storage.list_prefix("library/extracted/"): - if not key.endswith(".json"): - continue - try: - data = storage.read_json(key) - mpn = data.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "") - if mpn in seen_ic: - continue - seen_ic.add(mpn) - ics.append({ - "mpn": mpn, - "type": "ic", - "subtype": data.get("component_subtype", ""), - "pin_count": len(data.get("pintable", [])), - "has_ratings": bool(data.get("absolute_maximum_ratings")), - "has_datasheet": bool(resolve_datasheet(storage, mpn)), - }) - except Exception: - continue - - passives: list[dict] = [] - seen_p: set[str] = set() - for key in storage.list_prefix("library/patterns/"): - if not key.endswith(".json"): - continue - try: - data = storage.read_json(key) - name = data.get("name") or key.rsplit("/", 1)[-1].replace(".json", "") - if name in seen_p: - continue - seen_p.add(name) - passives.append({ - "mpn": name, - "type": "passive", - "subtype": data.get("component_type", ""), - "description": data.get("description", ""), - "regex": data.get("regex", ""), - }) - except Exception: - continue - - simple_models: list[dict] = [] - passive_parts: list[dict] = [] - seen_m: set[str] = set() - for prefix, row_type, dest in ( - ("library/passives/", "passive_part", passive_parts), - ("library/models/", "simple", simple_models), - ): - for key in storage.list_prefix(prefix): - if not key.endswith(".json"): - continue - try: - data = storage.read_json(key) - row = _catalog_model_row(data, key, row_type=row_type) - if row["mpn"] in seen_m: - continue - seen_m.add(row["mpn"]) - row["has_datasheet"] = bool(resolve_datasheet(storage, row["mpn"])) - dest.append(row) - except Exception: - continue - - datasheets: list[dict] = [] - seen_d: set[str] = set() - for key in storage.list_prefix(REF_PREFIX): - if not key.endswith(".json"): - continue - try: - ref = storage.read_json(key) - mpn = ref.get("mpn") or key.rsplit("/", 1)[-1].replace(".json", "") - if mpn in seen_d: - continue - seen_d.add(mpn) - datasheets.append({ - "mpn": mpn, - "hash": ref.get("hash"), - "has_extraction": mpn in seen_ic, - "has_model": mpn in seen_m, - }) - except Exception: - continue - - ics.sort(key=lambda r: r["mpn"].lower()) - passives.sort(key=lambda r: r["mpn"].lower()) - passive_parts.sort(key=lambda r: r["mpn"].lower()) - simple_models.sort(key=lambda r: r["mpn"].lower()) - datasheets.sort(key=lambda r: r["mpn"].lower()) - return { - "ics": ics, - "passives": passives, - "passive_parts": passive_parts, - "simple": simple_models, - "datasheets": datasheets, - } - - -def list_library_patterns(storage: StorageBackend) -> list[str]: - return [k for k in storage.list_prefix("library/patterns/") if k.endswith(".json")] - - -def load_library_patterns(storage: StorageBackend): - from backend.periscopex.resolve_passives import load_patterns - from backend.services.storage import LocalStorageBackend - - if isinstance(storage, LocalStorageBackend): - d = storage._path("library/patterns") - if not d.is_dir(): - return [] - return load_patterns(str(d)) - import tempfile - - keys = list_library_patterns(storage) - if not keys: - return [] - with tempfile.TemporaryDirectory() as tmp: - dest = Path(tmp) / "patterns" - dest.mkdir() - for key in keys: - storage.download_to_local(key, dest / key.rsplit("/", 1)[-1]) - return load_patterns(str(dest)) diff --git a/periscope/src/frontend/content/changelog.md b/periscope/src/frontend/content/changelog.md index a328926..4312942 100644 --- a/periscope/src/frontend/content/changelog.md +++ b/periscope/src/frontend/content/changelog.md @@ -2,6 +2,15 @@ What's new in Periscope. +## 2.63.0 — 2026-09-21 — Library as its own product door + +Standalone `/library` + `/api/library`: browse, add a datasheet PDF without an exam, update a component card. Datasheet prelim stays on the analysis path. Tests grouped datasheet → library → schematic → PCB → AF+AI (extra HF flags, then deterministic investigate). Rust criteria documented; no rustup. FEM out. DRC remains KiCad. + +- [New] `POST /api/library/datasheets` stores a PDF by MPN with no project id. +- [New] `GET`/`PUT /api/library/components/{type}/{mpn}` for extracted cards. +- [New] Library page import form; empty library no longer says “run a review”. +- [New] `investigate_hf_hypotheses` (`PE-AF-001`) — AI Z0 flag without stackup is INSUFFICIENT, never 50/90 Ω. Existing `run_pcb_checks` unchanged. + ## 2.62.1 — 2026-09-21 — PCB analysis remainder (Phase B) Stackup vs fab spec, via current with datasheet I (no IPC via chart), ESD pad distance TVS–connector, USB-PD contract, PoE isolation voltage, antenna keepout/matching, PDN Z(f). Same pipeline and finding quality as 2.61.2 / 2.62.0. Skip if no evidence. Not DRC. Not FEM. diff --git a/periscope/src/frontend/package.json b/periscope/src/frontend/package.json index c40a2cb..05bfd94 100644 --- a/periscope/src/frontend/package.json +++ b/periscope/src/frontend/package.json @@ -1,6 +1,6 @@ { "name": "periscope-web", - "version": "2.62.1", + "version": "2.63.0", "private": true, "scripts": { "sync-version": "node scripts/sync-version.mjs", diff --git a/periscope/src/frontend/src/app/(app)/library/library-editor.tsx b/periscope/src/frontend/src/app/(app)/library/library-editor.tsx new file mode 100644 index 0000000..0685375 --- /dev/null +++ b/periscope/src/frontend/src/app/(app)/library/library-editor.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + fetchLibraryComponent, + updateLibraryComponent, + type LibraryComponentType, +} from "@/lib/api"; + +export function LibraryEditor({ + componentType, + name, + onClose, + onSaved, +}: { + componentType: LibraryComponentType; + name: string; + onClose: () => void; + onSaved: () => void; +}) { + const [text, setText] = useState(""); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + fetchLibraryComponent(componentType, name) + .then((data) => { + if (!cancelled) setText(JSON.stringify(data, null, 2)); + }) + .catch((e) => { + if (!cancelled) { + setError(e instanceof Error ? e.message : "Failed to load"); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [componentType, name]); + + async function save() { + setError(null); + let payload: Record; + try { + const parsed: unknown = JSON.parse(text); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + setError("Body must be a JSON object."); + return; + } + payload = parsed as Record; + } catch { + setError("Invalid JSON."); + return; + } + setBusy(true); + try { + await updateLibraryComponent(componentType, name, payload); + onSaved(); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Save failed"); + } finally { + setBusy(false); + } + } + + return ( +
+
+

{name}

+ +
+ {loading ? ( +

Loading…

+ ) : ( +