sih-br-mcp 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +466 -540
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
package/dist/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ import { eraNotes, resetSidecars, loadSidecars, provenanceFor, raceNotes, withPr
|
|
|
10
10
|
import { getFreshness } from "./freshness.js";
|
|
11
11
|
import { outputSchemaFor } from "./output-schemas.js";
|
|
12
12
|
import { CLASSE_DO_ERRO_META, classeDaExcecao, withUpstreamCall } from "./upstream.js";
|
|
13
|
+
import { FalhaDaFonte } from "./erros.js";
|
|
13
14
|
import { CUBES_BASE_URL, CUBES_CACHE_ENABLED, cachedCubesManifest, causasSummaryState, cubesCacheDir, ensureCausasEstratosYears, ensureCausasSummary, ensureEstratosYears, ensureIcsapSummary, ensurePopulation, ensureSidecars, ensureYears, icsapSummaryState, loadCubesManifest, populationPresent, publishedYears, yearsFromArgs } from "./cache.js";
|
|
14
15
|
// =============================================================================
|
|
15
16
|
// DEFINIÇÃO DAS FERRAMENTAS
|
|
@@ -626,63 +627,55 @@ async function describeCubesChannel() {
|
|
|
626
627
|
};
|
|
627
628
|
}
|
|
628
629
|
async function handleGetAvailableYears() {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
csap_universe:
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
};
|
|
679
|
-
}
|
|
680
|
-
catch (error) {
|
|
681
|
-
return {
|
|
682
|
-
error: error instanceof Error ? error.message : "Erro ao listar anos",
|
|
683
|
-
years: [],
|
|
684
|
-
};
|
|
685
|
-
}
|
|
630
|
+
const years = getAvailableYears();
|
|
631
|
+
const noRace = yearsWithoutRace();
|
|
632
|
+
// Sidecars dos anos carregados (builder >= 2.5.0 traz os campos da era
|
|
633
|
+
// antiga; um sidecar anterior cai nos valores de 1998+).
|
|
634
|
+
const sidecars = loadSidecars().filter((s) => years.includes(s.cube_year));
|
|
635
|
+
const byYear = (f) => Object.fromEntries(sidecars.map((s) => [String(s.cube_year), f(s)]));
|
|
636
|
+
return {
|
|
637
|
+
years,
|
|
638
|
+
data_range: {
|
|
639
|
+
first_year: years[0],
|
|
640
|
+
last_year: years[years.length - 1],
|
|
641
|
+
total_years: years.length,
|
|
642
|
+
},
|
|
643
|
+
note: "Anos com dados Parquet disponíveis",
|
|
644
|
+
// Raça/cor por ano (sidecar `columns_missing`, builder >= 2.4.0): RACA_COR
|
|
645
|
+
// só entra no leiaute da AIH em 2008; em 1998–2007 `race` é nulo.
|
|
646
|
+
race_available: Object.fromEntries(years.map((y) => [String(y), !noRace.includes(y)])),
|
|
647
|
+
years_without_race: noRace,
|
|
648
|
+
// Era antiga 1992–1997 (sidecar do builder >= 2.5.0; docs/analise-002 e
|
|
649
|
+
// analise-003): revisão da CID por ano (internações por revisão; 1997 tem
|
|
650
|
+
// as duas), lista ICSAP por revisão, base do eixo `uf`, município,
|
|
651
|
+
// moeda de `value` e internações sem data na fonte.
|
|
652
|
+
cid_revision: byYear((s) => s.cid_revision ?? { "10": s.totals?.records_in_cube ?? null }),
|
|
653
|
+
years_cid9: yearsCid9(),
|
|
654
|
+
icsap_available: byYear(() => true),
|
|
655
|
+
icsap_list_revision: byYear((s) => s.icsap_list_revision ?? { "10": "portaria-221-2008" }),
|
|
656
|
+
uf_basis: byYear((s) => s.uf_basis ?? "residencia"),
|
|
657
|
+
years_uf_arquivo: yearsUfArquivo(),
|
|
658
|
+
municipality_available: byYear((s) => s.municipality_available ?? true),
|
|
659
|
+
currency: byYear((s) => s.currency ?? null),
|
|
660
|
+
records_date_imputed: byYear((s) => s.records_date_imputed ?? 0),
|
|
661
|
+
// Universo do % ICSAP (builder >= 2.6.0, csapAIH): internações dentro do
|
|
662
|
+
// universo e fora dele por motivo; null em cubo anterior a 2.6.0.
|
|
663
|
+
csap_universe: byYear((s) => s.csap_universe
|
|
664
|
+
? { method: s.csap_universe.method, records_in_universe: s.csap_universe.records_in_universe, excluded: s.csap_universe.excluded }
|
|
665
|
+
: null),
|
|
666
|
+
// Cobertura dos arquivos de população, lida deles: é o que as ferramentas
|
|
667
|
+
// de taxa aceitam — `detailed` (idade simples, 2000+) e `aggregated`
|
|
668
|
+
// (faixa etária quinquenal, 1991–1999; sih:taxas-1992-1999).
|
|
669
|
+
population_years: await getPopulationCoverage(),
|
|
670
|
+
// Canal público dos cubos (src/cache.ts): o que existe para baixar e onde
|
|
671
|
+
// o cache local vive. `published_years` vem do manifest.json do canal
|
|
672
|
+
// (timeout curto; cópia em disco quando a rede falha); `years` acima são
|
|
673
|
+
// os cubos já presentes localmente.
|
|
674
|
+
cubes_channel: await describeCubesChannel(),
|
|
675
|
+
// Frescor dos cubos frente ao espelho healthbr-data (src/freshness.ts):
|
|
676
|
+
// checado em segundo plano na inicialização, sem bloquear.
|
|
677
|
+
freshness: getFreshness(),
|
|
678
|
+
};
|
|
686
679
|
}
|
|
687
680
|
/**
|
|
688
681
|
* Forma de um código CID-10: letra + 2 dígitos, com um 4º caractere opcional
|
|
@@ -804,145 +797,121 @@ async function handleGetHospitalizations(args) {
|
|
|
804
797
|
races: args.race,
|
|
805
798
|
isCsap: args.is_csap,
|
|
806
799
|
};
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
};
|
|
837
|
-
}
|
|
838
|
-
catch (error) {
|
|
839
|
-
return {
|
|
840
|
-
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
841
|
-
data: [],
|
|
842
|
-
};
|
|
843
|
-
}
|
|
800
|
+
const data = await queryCausas({
|
|
801
|
+
filters,
|
|
802
|
+
groupBy: args.group_by,
|
|
803
|
+
metrics: ["n", "days", "value", "deaths"],
|
|
804
|
+
orderBy: args.group_by?.includes("year")
|
|
805
|
+
? "year"
|
|
806
|
+
: args.group_by?.[0] || "n_hospitalizations DESC",
|
|
807
|
+
limit: args.limit,
|
|
808
|
+
});
|
|
809
|
+
const totals = data.reduce((acc, row) => ({
|
|
810
|
+
n: acc.n + (Number(row.n_hospitalizations) || 0),
|
|
811
|
+
days: acc.days + (Number(row.total_days) || 0),
|
|
812
|
+
value: acc.value + (Number(row.total_value) || 0),
|
|
813
|
+
deaths: acc.deaths + (Number(row.deaths) || 0),
|
|
814
|
+
}), { n: 0, days: 0, value: 0, deaths: 0 });
|
|
815
|
+
const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, causasYearsAvailable()) : [];
|
|
816
|
+
return {
|
|
817
|
+
data,
|
|
818
|
+
...notesField(args.year, { value: true, month: !!args.month?.length || !!args.group_by?.includes("month") }, raceN),
|
|
819
|
+
summary: {
|
|
820
|
+
total_hospitalizations: totals.n,
|
|
821
|
+
total_days: totals.days,
|
|
822
|
+
total_value: Math.round(totals.value * 100) / 100,
|
|
823
|
+
deaths: totals.deaths,
|
|
824
|
+
hospital_mortality_rate: totals.n > 0 ? Math.round((totals.deaths / totals.n) * 10000) / 100 : 0,
|
|
825
|
+
records_returned: data.length,
|
|
826
|
+
},
|
|
827
|
+
filters_applied: args,
|
|
828
|
+
};
|
|
844
829
|
}
|
|
845
830
|
async function handleGetHospitalizationTrends(args) {
|
|
846
831
|
const { year_start, year_end, uf, cid_chapter, granularity = "yearly" } = args;
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
yearMonthEnd: `${year_end}-12`,
|
|
854
|
-
ufs: uf,
|
|
855
|
-
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
856
|
-
},
|
|
857
|
-
groupBy: ["year_month"],
|
|
858
|
-
orderBy: "year_month",
|
|
859
|
-
});
|
|
860
|
-
return {
|
|
861
|
-
granularity: "monthly",
|
|
862
|
-
series: data,
|
|
863
|
-
period: { start: `${year_start}-01`, end: `${year_end}-12` },
|
|
864
|
-
...notesField(yearsFromArgs(args) ?? undefined, { month: true }),
|
|
865
|
-
};
|
|
866
|
-
}
|
|
867
|
-
else {
|
|
868
|
-
// Agregação anual do CUBO DE SÉRIES (0.14.1; era do de causas): mesma
|
|
869
|
-
// resposta, provada grupo a grupo — e o cache baixa ~40 KB/ano em vez
|
|
870
|
-
// dos três cubos (~70 MB/ano). Foi a pergunta "total do Brasil desde
|
|
871
|
-
// 1992" levando 4 min no chat que expôs a diferença.
|
|
872
|
-
const years = [];
|
|
873
|
-
for (let y = year_start; y <= year_end; y++) {
|
|
874
|
-
years.push(y);
|
|
875
|
-
}
|
|
876
|
-
const data = await querySeriesYearly({
|
|
877
|
-
years,
|
|
832
|
+
if (granularity === "monthly") {
|
|
833
|
+
// Usa cubo de séries temporais
|
|
834
|
+
const data = await querySeries({
|
|
835
|
+
filters: {
|
|
836
|
+
yearMonthStart: `${year_start}-01`,
|
|
837
|
+
yearMonthEnd: `${year_end}-12`,
|
|
878
838
|
ufs: uf,
|
|
879
839
|
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
880
|
-
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
840
|
+
},
|
|
841
|
+
groupBy: ["year_month"],
|
|
842
|
+
orderBy: "year_month",
|
|
843
|
+
});
|
|
844
|
+
return {
|
|
845
|
+
granularity: "monthly",
|
|
846
|
+
series: data,
|
|
847
|
+
period: { start: `${year_start}-01`, end: `${year_end}-12` },
|
|
848
|
+
...notesField(yearsFromArgs(args) ?? undefined, { month: true }),
|
|
849
|
+
};
|
|
888
850
|
}
|
|
889
|
-
|
|
851
|
+
else {
|
|
852
|
+
// Agregação anual do CUBO DE SÉRIES (0.14.1; era do de causas): mesma
|
|
853
|
+
// resposta, provada grupo a grupo — e o cache baixa ~40 KB/ano em vez
|
|
854
|
+
// dos três cubos (~70 MB/ano). Foi a pergunta "total do Brasil desde
|
|
855
|
+
// 1992" levando 4 min no chat que expôs a diferença.
|
|
856
|
+
const years = [];
|
|
857
|
+
for (let y = year_start; y <= year_end; y++) {
|
|
858
|
+
years.push(y);
|
|
859
|
+
}
|
|
860
|
+
const data = await querySeriesYearly({
|
|
861
|
+
years,
|
|
862
|
+
ufs: uf,
|
|
863
|
+
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
864
|
+
});
|
|
890
865
|
return {
|
|
891
|
-
|
|
892
|
-
series:
|
|
866
|
+
granularity: "yearly",
|
|
867
|
+
series: data,
|
|
868
|
+
period: { start: year_start, end: year_end },
|
|
869
|
+
...notesField(yearsFromArgs(args) ?? undefined, { month: args.granularity === "monthly" }),
|
|
893
870
|
};
|
|
894
871
|
}
|
|
895
872
|
}
|
|
896
873
|
async function handleCompareRegions(args) {
|
|
897
874
|
const { year, compare_by = "uf", cid_chapter, is_csap, metric = "n", limit = 10 } = args;
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
875
|
+
const groupByField = compare_by === "region" ? "uf" : "uf"; // TODO: agregar por região
|
|
876
|
+
// Cubo LEVE quando o recorte cabe nele (0.14.2): esta ferramenta devolve só
|
|
877
|
+
// contagem e óbitos, que as séries têm — 1,2 MB nos 34 anos contra 1.253 MB.
|
|
878
|
+
const orderBy = metric === "n" ? "n_hospitalizations DESC" : "deaths DESC";
|
|
879
|
+
const data = seriesFitsCall("compare_regions", args)
|
|
880
|
+
? await querySeriesYearly({
|
|
881
|
+
years: year ?? [],
|
|
882
|
+
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
883
|
+
groupBy: [groupByField],
|
|
884
|
+
orderBy,
|
|
885
|
+
limit,
|
|
886
|
+
})
|
|
887
|
+
: await queryCausas({
|
|
888
|
+
filters: {
|
|
889
|
+
years: year,
|
|
906
890
|
cidChapters: cid_chapter ? [cid_chapter] : undefined,
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
}));
|
|
932
|
-
return {
|
|
933
|
-
compare_by,
|
|
934
|
-
metric,
|
|
935
|
-
ranking,
|
|
936
|
-
total_locations: data.length,
|
|
937
|
-
...notesField(args.year, {}),
|
|
938
|
-
};
|
|
939
|
-
}
|
|
940
|
-
catch (error) {
|
|
941
|
-
return {
|
|
942
|
-
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
943
|
-
ranking: [],
|
|
944
|
-
};
|
|
945
|
-
}
|
|
891
|
+
isCsap: is_csap,
|
|
892
|
+
},
|
|
893
|
+
groupBy: [groupByField],
|
|
894
|
+
metrics: ["n", "deaths"],
|
|
895
|
+
orderBy,
|
|
896
|
+
limit,
|
|
897
|
+
});
|
|
898
|
+
// Adiciona ranking
|
|
899
|
+
const ranking = data.map((row, index) => ({
|
|
900
|
+
rank: index + 1,
|
|
901
|
+
uf: row.uf,
|
|
902
|
+
n_hospitalizations: row.n_hospitalizations,
|
|
903
|
+
deaths: row.deaths,
|
|
904
|
+
mortality_rate: Number(row.n_hospitalizations) > 0
|
|
905
|
+
? Math.round((Number(row.deaths) / Number(row.n_hospitalizations)) * 10000) / 100
|
|
906
|
+
: 0,
|
|
907
|
+
}));
|
|
908
|
+
return {
|
|
909
|
+
compare_by,
|
|
910
|
+
metric,
|
|
911
|
+
ranking,
|
|
912
|
+
total_locations: data.length,
|
|
913
|
+
...notesField(args.year, {}),
|
|
914
|
+
};
|
|
946
915
|
}
|
|
947
916
|
async function handleGetIcsap(args) {
|
|
948
917
|
const filters = {
|
|
@@ -955,51 +924,43 @@ async function handleGetIcsap(args) {
|
|
|
955
924
|
ageMax: args.age_max,
|
|
956
925
|
races: args.race,
|
|
957
926
|
};
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
};
|
|
996
|
-
}
|
|
997
|
-
catch (error) {
|
|
998
|
-
return {
|
|
999
|
-
error: error instanceof Error ? error.message : "Erro na consulta",
|
|
1000
|
-
data: [],
|
|
1001
|
-
};
|
|
1002
|
-
}
|
|
927
|
+
const data = await queryIcsap({
|
|
928
|
+
filters,
|
|
929
|
+
groupBy: args.group_by,
|
|
930
|
+
metrics: ["n", "n_total", "days", "value", "deaths"],
|
|
931
|
+
universe: args.universe,
|
|
932
|
+
orderBy: args.group_by?.includes("year")
|
|
933
|
+
? "year"
|
|
934
|
+
: args.group_by?.[0] || "n_icsap DESC",
|
|
935
|
+
});
|
|
936
|
+
// Totais do filtro inteiro, numa consulta sem agrupamento (0.9.0): somar
|
|
937
|
+
// as linhas de `data` repetiria n_total sempre que o agrupamento divide o
|
|
938
|
+
// estrato (por grupo CSAP) — o denominador vem dos estratos distintos.
|
|
939
|
+
const [whole] = await calculateIcsapIndicators({ filters, groupBy: [], universe: args.universe });
|
|
940
|
+
const totals = {
|
|
941
|
+
icsap: Number(whole?.n_icsap) || 0,
|
|
942
|
+
total: Number(whole?.n_total) || 0,
|
|
943
|
+
days: Number(whole?.total_days) || 0,
|
|
944
|
+
value: Number(whole?.total_value) || 0,
|
|
945
|
+
deaths: Number(whole?.deaths) || 0,
|
|
946
|
+
};
|
|
947
|
+
const raceN = args.race?.length || args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
|
|
948
|
+
return {
|
|
949
|
+
data,
|
|
950
|
+
...notesField(args.year, { icsap: true, value: true, municipality: !!args.municipality_code || !!args.group_by?.includes("municipality_code") }, [...raceN, universeNote(args.universe)]),
|
|
951
|
+
summary: {
|
|
952
|
+
total_icsap: totals.icsap,
|
|
953
|
+
total_hospitalizations: totals.total,
|
|
954
|
+
icsap_percentage: totals.total > 0
|
|
955
|
+
? Math.round((totals.icsap / totals.total) * 10000) / 100
|
|
956
|
+
: 0,
|
|
957
|
+
total_days: totals.days,
|
|
958
|
+
total_value: Math.round(totals.value * 100) / 100,
|
|
959
|
+
deaths: totals.deaths,
|
|
960
|
+
records_returned: data.length,
|
|
961
|
+
},
|
|
962
|
+
filters_applied: args,
|
|
963
|
+
};
|
|
1003
964
|
}
|
|
1004
965
|
async function handleGetIcsapIndicators(args) {
|
|
1005
966
|
const filters = {
|
|
@@ -1010,26 +971,18 @@ async function handleGetIcsapIndicators(args) {
|
|
|
1010
971
|
ageMin: args.age_min,
|
|
1011
972
|
ageMax: args.age_max,
|
|
1012
973
|
};
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
};
|
|
1026
|
-
}
|
|
1027
|
-
catch (error) {
|
|
1028
|
-
return {
|
|
1029
|
-
error: error instanceof Error ? error.message : "Erro no cálculo",
|
|
1030
|
-
data: [],
|
|
1031
|
-
};
|
|
1032
|
-
}
|
|
974
|
+
const data = await calculateIcsapIndicators({
|
|
975
|
+
filters,
|
|
976
|
+
groupBy: args.group_by,
|
|
977
|
+
universe: args.universe,
|
|
978
|
+
});
|
|
979
|
+
const raceN = args.group_by?.includes("race") ? raceNotes(args.year, getAvailableYears()) : [];
|
|
980
|
+
return {
|
|
981
|
+
data,
|
|
982
|
+
...notesField(args.year, { icsap: true, value: true }, [...raceN, universeNote(args.universe)]),
|
|
983
|
+
indicators_calculated: ["icsap_percentage"],
|
|
984
|
+
note: "icsap_percentage = (n_icsap / n_total) * 100",
|
|
985
|
+
};
|
|
1033
986
|
}
|
|
1034
987
|
async function handleRankCsapGroups(args) {
|
|
1035
988
|
const filters = {
|
|
@@ -1039,61 +992,52 @@ async function handleRankCsapGroups(args) {
|
|
|
1039
992
|
ageMin: args.age_min,
|
|
1040
993
|
ageMax: args.age_max,
|
|
1041
994
|
};
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
const
|
|
1052
|
-
const group = csapGroups.groups.find((g) => g.code === row.csap_group);
|
|
1053
|
-
return {
|
|
1054
|
-
rank: index + 1,
|
|
1055
|
-
csap_group: row.csap_group,
|
|
1056
|
-
csap_name: group?.name_pt || "Desconhecido",
|
|
1057
|
-
metric_value: row.metric_value,
|
|
1058
|
-
pct_of_total: totalMetric > 0
|
|
1059
|
-
? Math.round((Number(row.metric_value) / totalMetric) * 10000) / 100
|
|
1060
|
-
: 0,
|
|
1061
|
-
n_hospitalizations: row.n_hospitalizations,
|
|
1062
|
-
total_days: row.total_days,
|
|
1063
|
-
total_value: row.total_value,
|
|
1064
|
-
deaths: row.deaths,
|
|
1065
|
-
};
|
|
1066
|
-
});
|
|
1067
|
-
// Concentração nos top 3 e top 5
|
|
1068
|
-
const top3Pct = ranking.slice(0, 3).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
1069
|
-
const top5Pct = ranking.slice(0, 5).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
1070
|
-
return {
|
|
1071
|
-
metric: args.metric || "n",
|
|
1072
|
-
ranking,
|
|
1073
|
-
...notesField(args.year, { icsap: true, value: true }, [universeNote(args.universe)]),
|
|
1074
|
-
concentration: {
|
|
1075
|
-
top_3_percentage: Math.round(top3Pct * 100) / 100,
|
|
1076
|
-
top_5_percentage: Math.round(top5Pct * 100) / 100,
|
|
1077
|
-
},
|
|
1078
|
-
total_groups: ranking.length,
|
|
1079
|
-
};
|
|
1080
|
-
}
|
|
1081
|
-
catch (error) {
|
|
995
|
+
const data = await rankCsapGroups({
|
|
996
|
+
filters,
|
|
997
|
+
metric: args.metric || "n",
|
|
998
|
+
limit: args.limit || 19,
|
|
999
|
+
universe: args.universe,
|
|
1000
|
+
});
|
|
1001
|
+
// Adiciona nomes dos grupos e calcula percentuais
|
|
1002
|
+
const totalMetric = data.reduce((acc, row) => acc + (Number(row.metric_value) || 0), 0);
|
|
1003
|
+
const ranking = data.map((row, index) => {
|
|
1004
|
+
const group = csapGroups.groups.find((g) => g.code === row.csap_group);
|
|
1082
1005
|
return {
|
|
1083
|
-
|
|
1084
|
-
|
|
1006
|
+
rank: index + 1,
|
|
1007
|
+
csap_group: row.csap_group,
|
|
1008
|
+
csap_name: group?.name_pt || "Desconhecido",
|
|
1009
|
+
metric_value: row.metric_value,
|
|
1010
|
+
pct_of_total: totalMetric > 0
|
|
1011
|
+
? Math.round((Number(row.metric_value) / totalMetric) * 10000) / 100
|
|
1012
|
+
: 0,
|
|
1013
|
+
n_hospitalizations: row.n_hospitalizations,
|
|
1014
|
+
total_days: row.total_days,
|
|
1015
|
+
total_value: row.total_value,
|
|
1016
|
+
deaths: row.deaths,
|
|
1085
1017
|
};
|
|
1086
|
-
}
|
|
1018
|
+
});
|
|
1019
|
+
// Concentração nos top 3 e top 5
|
|
1020
|
+
const top3Pct = ranking.slice(0, 3).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
1021
|
+
const top5Pct = ranking.slice(0, 5).reduce((acc, r) => acc + r.pct_of_total, 0);
|
|
1022
|
+
return {
|
|
1023
|
+
metric: args.metric || "n",
|
|
1024
|
+
ranking,
|
|
1025
|
+
...notesField(args.year, { icsap: true, value: true }, [universeNote(args.universe)]),
|
|
1026
|
+
concentration: {
|
|
1027
|
+
top_3_percentage: Math.round(top3Pct * 100) / 100,
|
|
1028
|
+
top_5_percentage: Math.round(top5Pct * 100) / 100,
|
|
1029
|
+
},
|
|
1030
|
+
total_groups: ranking.length,
|
|
1031
|
+
};
|
|
1087
1032
|
}
|
|
1088
1033
|
async function handleGetHospitalizationRates(args) {
|
|
1089
1034
|
// Verifica se dados populacionais estão disponíveis (pasta de dados ou cache
|
|
1090
1035
|
// local, enchido do canal por ensurePopulation() antes deste handler)
|
|
1091
1036
|
if (!hasPopulationData()) {
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
};
|
|
1037
|
+
// Falha da FONTE (o download do bloco population não chegou), não ausência
|
|
1038
|
+
// respondida: sai como erro de verdade, com classe, e não como `{ error }`
|
|
1039
|
+
// num sucesso que a telemetria contava `ok` (decisão 49).
|
|
1040
|
+
throw new FalhaDaFonte(`${POPULATION_MISSING_MESSAGE} Esta ferramenta requer pop_uf.parquet (ou pop_uf_agregado/pop_municipios) em ${getPopulationDir()}; o canal ${CUBES_BASE_URL} publica os três no bloco population do manifest.json.`);
|
|
1097
1041
|
}
|
|
1098
1042
|
// Valida anos pela cobertura REAL dos arquivos de população (lida deles, não
|
|
1099
1043
|
// fixada aqui): idade simples de 2000 em diante (pop_uf) e faixa etária
|
|
@@ -1148,124 +1092,116 @@ async function handleGetHospitalizationRates(args) {
|
|
|
1148
1092
|
const popSources = {};
|
|
1149
1093
|
for (const y of validYears)
|
|
1150
1094
|
popSources[y] = await populationSourceFor(y);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
population = await getPopulation({ year: popYear, uf: normalizedUfs, sex: args.sex, ageMin: args.age_min, ageMax: args.age_max });
|
|
1186
|
-
}
|
|
1187
|
-
catch { /* ignore */ }
|
|
1188
|
-
return {
|
|
1189
|
-
data: [],
|
|
1190
|
-
summary: {
|
|
1191
|
-
total_hospitalizations: 0,
|
|
1192
|
-
total_population: population,
|
|
1193
|
-
overall_rate: 0,
|
|
1194
|
-
rate_per: ratePer,
|
|
1195
|
-
rate_type: args.rate_type || "crude",
|
|
1196
|
-
},
|
|
1197
|
-
metadata: {
|
|
1198
|
-
population_source: popSources,
|
|
1199
|
-
filters_applied: { ...args, uf: normalizedUfs, year: validYears },
|
|
1200
|
-
note: "Nenhuma internação encontrada para os filtros aplicados.",
|
|
1201
|
-
available_sih_years: availableYears,
|
|
1202
|
-
},
|
|
1203
|
-
};
|
|
1204
|
-
}
|
|
1205
|
-
// Busca população correspondente e calcula taxas
|
|
1206
|
-
const results = [];
|
|
1207
|
-
for (const row of validHospData) {
|
|
1208
|
-
const hospYear = hasYearGrouping ? Number(row.year) : validYears[0];
|
|
1209
|
-
const hospUf = hasUfGrouping ? String(row.uf) : undefined;
|
|
1210
|
-
// Busca população para o estrato
|
|
1211
|
-
let population = 0;
|
|
1212
|
-
try {
|
|
1213
|
-
population = await getPopulation({
|
|
1214
|
-
year: hospYear,
|
|
1215
|
-
uf: hospUf ? [hospUf] : normalizedUfs,
|
|
1216
|
-
sex: args.sex,
|
|
1217
|
-
ageMin: args.age_min,
|
|
1218
|
-
ageMax: args.age_max,
|
|
1219
|
-
});
|
|
1220
|
-
}
|
|
1221
|
-
catch (popError) {
|
|
1222
|
-
console.error(`[get_hospitalization_rates] Erro população (year=${hospYear}, uf=${hospUf}): ${popError}`);
|
|
1223
|
-
}
|
|
1224
|
-
const nHosp = Number(row.n_hospitalizations) || 0;
|
|
1225
|
-
const deaths = Number(row.deaths) || 0;
|
|
1226
|
-
const rate = population > 0 ? (nHosp / population) * ratePer : 0;
|
|
1227
|
-
results.push({
|
|
1228
|
-
...(hasYearGrouping ? { year: hospYear } : {}),
|
|
1229
|
-
...(hasUfGrouping ? { uf: hospUf } : {}),
|
|
1230
|
-
n_hospitalizations: nHosp,
|
|
1231
|
-
deaths,
|
|
1232
|
-
population,
|
|
1233
|
-
rate: Math.round(rate * 100) / 100,
|
|
1234
|
-
rate_per: ratePer,
|
|
1235
|
-
// Deprecado: mantido uma versão para quem lia o nome antigo. Até a 1.1.x
|
|
1236
|
-
// este campo carregava a taxa na base `rate_per` (por mil, por 10 mil),
|
|
1237
|
-
// contradizendo o próprio nome; agora vale sempre por 100 mil.
|
|
1238
|
-
rate_per_100k: population > 0 ? Math.round((nHosp / population) * 100000 * 100) / 100 : 0,
|
|
1239
|
-
population_source: popSources[hospYear] ?? null,
|
|
1240
|
-
mortality_rate: nHosp > 0 ? Math.round((deaths / nHosp) * 10000) / 100 : 0,
|
|
1241
|
-
});
|
|
1095
|
+
// Define agrupamento: se uf ou year foram passados sem group_by, agrupa automaticamente
|
|
1096
|
+
const groupBy = args.group_by ? [...args.group_by] : [];
|
|
1097
|
+
if (normalizedUfs && normalizedUfs.length > 1 && !groupBy.includes("uf")) {
|
|
1098
|
+
groupBy.push("uf");
|
|
1099
|
+
}
|
|
1100
|
+
if (validYears.length > 1 && !groupBy.includes("year")) {
|
|
1101
|
+
groupBy.push("year");
|
|
1102
|
+
}
|
|
1103
|
+
const hasUfGrouping = groupBy.includes("uf");
|
|
1104
|
+
const hasYearGrouping = groupBy.includes("year");
|
|
1105
|
+
// Busca internações — cubo LEVE quando o recorte cabe (0.14.2): a taxa usa
|
|
1106
|
+
// contagem e óbitos, que as séries têm. É o caminho da pergunta longa
|
|
1107
|
+
// ("taxa por 100 mil ao longo do tempo"), que no cubo de causas custava
|
|
1108
|
+
// 1.253 MB de download.
|
|
1109
|
+
const hospData = seriesFitsCall("get_hospitalization_rates", args)
|
|
1110
|
+
? await querySeriesYearly({
|
|
1111
|
+
years: validYears,
|
|
1112
|
+
ufs: normalizedUfs,
|
|
1113
|
+
cidChapters: args.cid_chapter,
|
|
1114
|
+
groupBy,
|
|
1115
|
+
})
|
|
1116
|
+
: await queryCausas({
|
|
1117
|
+
filters,
|
|
1118
|
+
groupBy,
|
|
1119
|
+
metrics: ["n", "deaths"],
|
|
1120
|
+
});
|
|
1121
|
+
// Filtra resultados nulos (quando GROUP BY vazio e sem dados, retorna {null, null})
|
|
1122
|
+
const validHospData = hospData.filter(row => row.n_hospitalizations !== null && row.n_hospitalizations !== undefined);
|
|
1123
|
+
if (validHospData.length === 0) {
|
|
1124
|
+
// Sem internações, mas podemos ainda fornecer a população
|
|
1125
|
+
const popYear = validYears[0];
|
|
1126
|
+
let population = 0;
|
|
1127
|
+
try {
|
|
1128
|
+
population = await getPopulation({ year: popYear, uf: normalizedUfs, sex: args.sex, ageMin: args.age_min, ageMax: args.age_max });
|
|
1242
1129
|
}
|
|
1243
|
-
|
|
1244
|
-
const totalHosp = results.reduce((acc, r) => acc + r.n_hospitalizations, 0);
|
|
1245
|
-
const totalPop = results.reduce((acc, r) => acc + r.population, 0);
|
|
1246
|
-
const overallRate = totalPop > 0 ? (totalHosp / totalPop) * ratePer : 0;
|
|
1130
|
+
catch { /* ignore */ }
|
|
1247
1131
|
return {
|
|
1248
|
-
data:
|
|
1132
|
+
data: [],
|
|
1249
1133
|
summary: {
|
|
1250
|
-
total_hospitalizations:
|
|
1251
|
-
total_population:
|
|
1252
|
-
overall_rate:
|
|
1134
|
+
total_hospitalizations: 0,
|
|
1135
|
+
total_population: population,
|
|
1136
|
+
overall_rate: 0,
|
|
1253
1137
|
rate_per: ratePer,
|
|
1254
1138
|
rate_type: args.rate_type || "crude",
|
|
1255
1139
|
},
|
|
1256
1140
|
metadata: {
|
|
1257
1141
|
population_source: popSources,
|
|
1258
|
-
population_notes: populationNotes(popSources),
|
|
1259
1142
|
filters_applied: { ...args, uf: normalizedUfs, year: validYears },
|
|
1143
|
+
note: "Nenhuma internação encontrada para os filtros aplicados.",
|
|
1144
|
+
available_sih_years: availableYears,
|
|
1260
1145
|
},
|
|
1261
1146
|
};
|
|
1262
1147
|
}
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1148
|
+
// Busca população correspondente e calcula taxas
|
|
1149
|
+
const results = [];
|
|
1150
|
+
for (const row of validHospData) {
|
|
1151
|
+
const hospYear = hasYearGrouping ? Number(row.year) : validYears[0];
|
|
1152
|
+
const hospUf = hasUfGrouping ? String(row.uf) : undefined;
|
|
1153
|
+
// Busca população para o estrato
|
|
1154
|
+
let population = 0;
|
|
1155
|
+
try {
|
|
1156
|
+
population = await getPopulation({
|
|
1157
|
+
year: hospYear,
|
|
1158
|
+
uf: hospUf ? [hospUf] : normalizedUfs,
|
|
1159
|
+
sex: args.sex,
|
|
1160
|
+
ageMin: args.age_min,
|
|
1161
|
+
ageMax: args.age_max,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
catch (popError) {
|
|
1165
|
+
console.error(`[get_hospitalization_rates] Erro população (year=${hospYear}, uf=${hospUf}): ${popError}`);
|
|
1166
|
+
}
|
|
1167
|
+
const nHosp = Number(row.n_hospitalizations) || 0;
|
|
1168
|
+
const deaths = Number(row.deaths) || 0;
|
|
1169
|
+
const rate = population > 0 ? (nHosp / population) * ratePer : 0;
|
|
1170
|
+
results.push({
|
|
1171
|
+
...(hasYearGrouping ? { year: hospYear } : {}),
|
|
1172
|
+
...(hasUfGrouping ? { uf: hospUf } : {}),
|
|
1173
|
+
n_hospitalizations: nHosp,
|
|
1174
|
+
deaths,
|
|
1175
|
+
population,
|
|
1176
|
+
rate: Math.round(rate * 100) / 100,
|
|
1177
|
+
rate_per: ratePer,
|
|
1178
|
+
// Deprecado: mantido uma versão para quem lia o nome antigo. Até a 1.1.x
|
|
1179
|
+
// este campo carregava a taxa na base `rate_per` (por mil, por 10 mil),
|
|
1180
|
+
// contradizendo o próprio nome; agora vale sempre por 100 mil.
|
|
1181
|
+
rate_per_100k: population > 0 ? Math.round((nHosp / population) * 100000 * 100) / 100 : 0,
|
|
1182
|
+
population_source: popSources[hospYear] ?? null,
|
|
1183
|
+
mortality_rate: nHosp > 0 ? Math.round((deaths / nHosp) * 10000) / 100 : 0,
|
|
1184
|
+
});
|
|
1268
1185
|
}
|
|
1186
|
+
// Calcula totais
|
|
1187
|
+
const totalHosp = results.reduce((acc, r) => acc + r.n_hospitalizations, 0);
|
|
1188
|
+
const totalPop = results.reduce((acc, r) => acc + r.population, 0);
|
|
1189
|
+
const overallRate = totalPop > 0 ? (totalHosp / totalPop) * ratePer : 0;
|
|
1190
|
+
return {
|
|
1191
|
+
data: results,
|
|
1192
|
+
summary: {
|
|
1193
|
+
total_hospitalizations: totalHosp,
|
|
1194
|
+
total_population: totalPop,
|
|
1195
|
+
overall_rate: Math.round(overallRate * 100) / 100,
|
|
1196
|
+
rate_per: ratePer,
|
|
1197
|
+
rate_type: args.rate_type || "crude",
|
|
1198
|
+
},
|
|
1199
|
+
metadata: {
|
|
1200
|
+
population_source: popSources,
|
|
1201
|
+
population_notes: populationNotes(popSources),
|
|
1202
|
+
filters_applied: { ...args, uf: normalizedUfs, year: validYears },
|
|
1203
|
+
},
|
|
1204
|
+
};
|
|
1269
1205
|
}
|
|
1270
1206
|
async function handleCompareIcsapTrends(args) {
|
|
1271
1207
|
const { start_year, end_year, compare_by, indicator, include_trend_line } = args;
|
|
@@ -1287,10 +1223,8 @@ async function handleCompareIcsapTrends(args) {
|
|
|
1287
1223
|
}
|
|
1288
1224
|
// Se usa taxa, verifica população
|
|
1289
1225
|
if (indicatorType === "rate_per_10k" && !hasPopulationData()) {
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
series: [],
|
|
1293
|
-
};
|
|
1226
|
+
// Falha da fonte, como em handleGetHospitalizationRates (decisão 49).
|
|
1227
|
+
throw new FalhaDaFonte(`Taxa por população requer dados populacionais. ${POPULATION_MISSING_MESSAGE}`);
|
|
1294
1228
|
}
|
|
1295
1229
|
// Normaliza compare_values (UFs para uppercase)
|
|
1296
1230
|
const compare_values = args.compare_values?.map(v => compare_by === "uf" ? v.toUpperCase() : v);
|
|
@@ -1305,152 +1239,144 @@ async function handleCompareIcsapTrends(args) {
|
|
|
1305
1239
|
if (years.length === 0) {
|
|
1306
1240
|
return respostaSemAnos(allYears, availableYears, "series");
|
|
1307
1241
|
}
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
seriesMap[compareKey] = {};
|
|
1334
|
-
}
|
|
1335
|
-
seriesMap[compareKey][year] = { icsap, total };
|
|
1336
|
-
}
|
|
1337
|
-
// Se precisa de taxa, busca população por ano
|
|
1338
|
-
if (indicatorType === "rate_per_10k") {
|
|
1339
|
-
for (const compareKey of Object.keys(seriesMap)) {
|
|
1340
|
-
for (const year of years) {
|
|
1341
|
-
if (seriesMap[compareKey][year]) {
|
|
1342
|
-
try {
|
|
1343
|
-
const ufFilter = compare_by === "uf" ? [compareKey] : undefined;
|
|
1344
|
-
const pop = await getPopulation({ year, uf: ufFilter });
|
|
1345
|
-
seriesMap[compareKey][year].population = pop;
|
|
1346
|
-
}
|
|
1347
|
-
catch {
|
|
1348
|
-
seriesMap[compareKey][year].population = 0;
|
|
1349
|
-
}
|
|
1350
|
-
}
|
|
1351
|
-
}
|
|
1352
|
-
}
|
|
1242
|
+
const filters = {
|
|
1243
|
+
years,
|
|
1244
|
+
csapGroups: compare_by === "csap_group" ? compare_values : undefined,
|
|
1245
|
+
ufs: compare_by === "uf" ? compare_values : undefined,
|
|
1246
|
+
};
|
|
1247
|
+
// Determina groupBy baseado em compare_by
|
|
1248
|
+
const groupBy = ["year"];
|
|
1249
|
+
if (compare_by) {
|
|
1250
|
+
groupBy.push(compare_by);
|
|
1251
|
+
}
|
|
1252
|
+
// Busca dados ICSAP
|
|
1253
|
+
const data = await calculateIcsapIndicators({
|
|
1254
|
+
filters,
|
|
1255
|
+
groupBy,
|
|
1256
|
+
universe: args.universe,
|
|
1257
|
+
});
|
|
1258
|
+
// Organiza série temporal
|
|
1259
|
+
const seriesMap = {};
|
|
1260
|
+
for (const row of data) {
|
|
1261
|
+
const year = Number(row.year);
|
|
1262
|
+
const compareKey = compare_by ? String(row[compare_by]) : "total";
|
|
1263
|
+
const icsap = Number(row.n_icsap) || 0;
|
|
1264
|
+
const total = Number(row.n_total) || 0;
|
|
1265
|
+
if (!seriesMap[compareKey]) {
|
|
1266
|
+
seriesMap[compareKey] = {};
|
|
1353
1267
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
for (const
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
value = entry.icsap;
|
|
1366
|
-
break;
|
|
1367
|
-
case "rate_per_10k":
|
|
1368
|
-
value = entry.population && entry.population > 0
|
|
1369
|
-
? (entry.icsap / entry.population) * 10000
|
|
1370
|
-
: 0;
|
|
1371
|
-
break;
|
|
1372
|
-
case "percentage":
|
|
1373
|
-
default:
|
|
1374
|
-
value = entry.total > 0 ? (entry.icsap / entry.total) * 100 : 0;
|
|
1268
|
+
seriesMap[compareKey][year] = { icsap, total };
|
|
1269
|
+
}
|
|
1270
|
+
// Se precisa de taxa, busca população por ano
|
|
1271
|
+
if (indicatorType === "rate_per_10k") {
|
|
1272
|
+
for (const compareKey of Object.keys(seriesMap)) {
|
|
1273
|
+
for (const year of years) {
|
|
1274
|
+
if (seriesMap[compareKey][year]) {
|
|
1275
|
+
try {
|
|
1276
|
+
const ufFilter = compare_by === "uf" ? [compareKey] : undefined;
|
|
1277
|
+
const pop = await getPopulation({ year, uf: ufFilter });
|
|
1278
|
+
seriesMap[compareKey][year].population = pop;
|
|
1375
1279
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
if (!trendData[compareKey]) {
|
|
1379
|
-
trendData[compareKey] = { values: [], years: [] };
|
|
1280
|
+
catch {
|
|
1281
|
+
seriesMap[compareKey][year].population = 0;
|
|
1380
1282
|
}
|
|
1381
|
-
trendData[compareKey].values.push(value);
|
|
1382
|
-
trendData[compareKey].years.push(year);
|
|
1383
1283
|
}
|
|
1384
1284
|
}
|
|
1385
|
-
series.push(row);
|
|
1386
1285
|
}
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1286
|
+
}
|
|
1287
|
+
// Constrói séries e calcula indicadores
|
|
1288
|
+
const series = [];
|
|
1289
|
+
const trendData = {};
|
|
1290
|
+
for (const year of years) {
|
|
1291
|
+
const row = { year };
|
|
1292
|
+
for (const compareKey of Object.keys(seriesMap)) {
|
|
1293
|
+
const entry = seriesMap[compareKey][year];
|
|
1294
|
+
if (entry) {
|
|
1295
|
+
let value;
|
|
1296
|
+
switch (indicatorType) {
|
|
1297
|
+
case "count":
|
|
1298
|
+
value = entry.icsap;
|
|
1299
|
+
break;
|
|
1300
|
+
case "rate_per_10k":
|
|
1301
|
+
value = entry.population && entry.population > 0
|
|
1302
|
+
? (entry.icsap / entry.population) * 10000
|
|
1303
|
+
: 0;
|
|
1304
|
+
break;
|
|
1305
|
+
case "percentage":
|
|
1306
|
+
default:
|
|
1307
|
+
value = entry.total > 0 ? (entry.icsap / entry.total) * 100 : 0;
|
|
1308
|
+
}
|
|
1309
|
+
row[compareKey] = Math.round(value * 100) / 100;
|
|
1310
|
+
// Acumula para cálculo de tendência
|
|
1311
|
+
if (!trendData[compareKey]) {
|
|
1312
|
+
trendData[compareKey] = { values: [], years: [] };
|
|
1313
|
+
}
|
|
1314
|
+
trendData[compareKey].values.push(value);
|
|
1315
|
+
trendData[compareKey].years.push(year);
|
|
1411
1316
|
}
|
|
1412
1317
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1318
|
+
series.push(row);
|
|
1319
|
+
}
|
|
1320
|
+
// Calcula tendências (regressão linear simples)
|
|
1321
|
+
const trends = {};
|
|
1322
|
+
if (includeTrend) {
|
|
1323
|
+
for (const [compareKey, td] of Object.entries(trendData)) {
|
|
1324
|
+
const n = td.values.length;
|
|
1325
|
+
if (n < 2)
|
|
1326
|
+
continue;
|
|
1327
|
+
// Regressão linear simples
|
|
1328
|
+
const sumX = td.years.reduce((a, b) => a + b, 0);
|
|
1329
|
+
const sumY = td.values.reduce((a, b) => a + b, 0);
|
|
1330
|
+
const sumXY = td.years.reduce((acc, x, i) => acc + x * td.values[i], 0);
|
|
1331
|
+
const sumX2 = td.years.reduce((acc, x) => acc + x * x, 0);
|
|
1332
|
+
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
|
|
1333
|
+
const startValue = td.values[0];
|
|
1334
|
+
const endValue = td.values[n - 1];
|
|
1335
|
+
const changePct = startValue > 0 ? ((endValue - startValue) / startValue) * 100 : 0;
|
|
1336
|
+
trends[compareKey] = {
|
|
1337
|
+
slope: Math.round(slope * 1000) / 1000,
|
|
1338
|
+
direction: slope > 0.1 ? "increasing" : slope < -0.1 ? "decreasing" : "stable",
|
|
1339
|
+
avg_annual_change: Math.round(slope * 100) / 100,
|
|
1340
|
+
start_value: Math.round(startValue * 100) / 100,
|
|
1341
|
+
end_value: Math.round(endValue * 100) / 100,
|
|
1342
|
+
change_pct: Math.round(changePct * 100) / 100,
|
|
1343
|
+
};
|
|
1428
1344
|
}
|
|
1429
|
-
return {
|
|
1430
|
-
indicator: indicatorType,
|
|
1431
|
-
period: { start: start_year, end: end_year },
|
|
1432
|
-
compare_by: compare_by || "total",
|
|
1433
|
-
series,
|
|
1434
|
-
...notesField(years, { icsap: true }, [
|
|
1435
|
-
universeNote(args.universe),
|
|
1436
|
-
...(indicatorType === "rate_per_10k" ? populationNotes(await popSourcesFor(years)) : []),
|
|
1437
|
-
]),
|
|
1438
|
-
trends: includeTrend ? trends : undefined,
|
|
1439
|
-
summary: {
|
|
1440
|
-
best_performer: bestPerformer,
|
|
1441
|
-
worst_performer: worstPerformer,
|
|
1442
|
-
note: indicatorType === "percentage"
|
|
1443
|
-
? "Para % ICSAP, queda indica melhoria na Atenção Primária"
|
|
1444
|
-
: undefined,
|
|
1445
|
-
},
|
|
1446
|
-
};
|
|
1447
1345
|
}
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1346
|
+
// Identifica melhor/pior desempenho (para percentage, menor é melhor)
|
|
1347
|
+
let bestPerformer;
|
|
1348
|
+
let worstPerformer;
|
|
1349
|
+
if (Object.keys(trends).length > 1) {
|
|
1350
|
+
const sortedByChange = Object.entries(trends).sort((a, b) => a[1].change_pct - b[1].change_pct);
|
|
1351
|
+
if (indicatorType === "percentage") {
|
|
1352
|
+
// Para porcentagem ICSAP, queda é melhor
|
|
1353
|
+
bestPerformer = sortedByChange[0][0];
|
|
1354
|
+
worstPerformer = sortedByChange[sortedByChange.length - 1][0];
|
|
1355
|
+
}
|
|
1356
|
+
else {
|
|
1357
|
+
// Para contagem e taxa, menor variação positiva ou maior queda é melhor
|
|
1358
|
+
bestPerformer = sortedByChange[0][0];
|
|
1359
|
+
worstPerformer = sortedByChange[sortedByChange.length - 1][0];
|
|
1360
|
+
}
|
|
1453
1361
|
}
|
|
1362
|
+
return {
|
|
1363
|
+
indicator: indicatorType,
|
|
1364
|
+
period: { start: start_year, end: end_year },
|
|
1365
|
+
compare_by: compare_by || "total",
|
|
1366
|
+
series,
|
|
1367
|
+
...notesField(years, { icsap: true }, [
|
|
1368
|
+
universeNote(args.universe),
|
|
1369
|
+
...(indicatorType === "rate_per_10k" ? populationNotes(await popSourcesFor(years)) : []),
|
|
1370
|
+
]),
|
|
1371
|
+
trends: includeTrend ? trends : undefined,
|
|
1372
|
+
summary: {
|
|
1373
|
+
best_performer: bestPerformer,
|
|
1374
|
+
worst_performer: worstPerformer,
|
|
1375
|
+
note: indicatorType === "percentage"
|
|
1376
|
+
? "Para % ICSAP, queda indica melhoria na Atenção Primária"
|
|
1377
|
+
: undefined,
|
|
1378
|
+
},
|
|
1379
|
+
};
|
|
1454
1380
|
}
|
|
1455
1381
|
// =============================================================================
|
|
1456
1382
|
// DESPACHO — o mesmo para os dois transportes (stdio em src/index.ts, HTTP em
|